diff --git a/.gitignore b/.gitignore index 89631fb..1821213 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.xcodeproj Package.resolved .DS_Store +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f95fbc5..ea52c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,160 @@ 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 +mana-swift-core ≥ 1.3.0 voraus (Status `.twoFactorRequired`). + +### Neu + +- `ManaTwoFactorChallengeView` + `TwoFactorChallengeViewModel` — + 6-stelliger TOTP-Code-Input (Number-Pad auf iOS), Fallback auf + Backup-Codes via Toggle, "Abbrechen" routet via + `auth.signOut(keepGuestMode:)` zurück zum Login. +- `LoginViewModel.Status.twoFactorRequired(email:)` als neuer Case. +- `ManaLoginView` schaltet bei `.twoFactorRequired` automatisch auf + `ManaTwoFactorChallengeView` um (analog zu `.emailNotVerified`). + +### Tests + +- 6 neue Tests für `TwoFactorChallengeViewModel`: canSubmit-Guards + (TOTP 6 Ziffern, Backup beliebig), toggleMode-State-Reset, submit + bei Erfolg/Fehler, Backup-Code-Routing. +- 39/39 grün. + +## [0.2.0] — 2026-05-13 + +Minor — Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. +Komplett additiv; braucht `mana-swift-core` ≥ 1.2.0. + +### ManaAuthUI — neu: ManaAuthGate + +- `ManaAuthGate` — `@Observable`-State-Maschine, die eine Aktion erst + laufen lässt, wenn der User eingeloggt ist. Wenn nicht, wird das + Sign-In-Sheet aufgeklappt und die Aktion gemerkt; nach erfolgreichem + Sign-In läuft sie automatisch. +- Zwei `require`-Overloads: synchron (`() -> Void`) und async + (`() async -> Void`). Konsumenten schreiben `gate.require { ... }` + ohne sich um das Gate-Lifecycle zu kümmern. +- `ManaAuthGateModifier` / `View.manaAuthGate(_:signIn:)` — hängt das + Sign-In-Sheet an einen Root-View und beobachtet `auth.status`. + Wechsel auf `.signedIn` schließt das Sheet und löst die Pending- + Aktion aus; manuelles Dismiss verwirft die Pending-Aktion. +- `lastReason` als optionaler Telemetrie-Hint pro `require`-Call. + +### Konvention + +Native-Apps sollen `mana-swift-core` v1.2.0 + Guest-Mode + diesen Gate +als Standardweg nutzen. Pattern für Cards/Manaspur/Memoro: + +1. Beim App-Start: `bootstrap()`, dann bei `.signedOut` → `enterGuestMode()`. +2. Root-View zeigt immer App-Inhalte; **nie** eine Vollbild-Login-Wall. +3. Aktionen, die einen Account brauchen, werden in + `gate.require { ... }` gewrappt — Login wird zur Inline-Eskalation + statt zum App-Block. + +Memoro hat dieses Muster informell schon umgesetzt (ContentView ohne +Hard-Gate). Cards + Manaspur ziehen mit ihren nächsten Releases nach. + +### Tests + +- 7 neue Tests für `ManaAuthGate`: sofortiger Run bei `.signedIn`, + Defer bei `.signedOut`/`.guest`, `resolvePending` nach Sign-In, + `cancelPending`, `lastReason`-Tracking. + ## [0.1.0] — 2026-05-13 Phase 2 aus dem Native-Auth-Vollausbau-Plan (Option A, siehe diff --git a/Package.swift b/Package.swift index 482de59..7b28ec9 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( ], products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), + .library(name: "ManaWebShell", targets: ["ManaWebShell"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über @@ -29,10 +30,22 @@ 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/Gate/ManaAuthGate.swift b/Sources/ManaAuthUI/Gate/ManaAuthGate.swift new file mode 100644 index 0000000..4ea9580 --- /dev/null +++ b/Sources/ManaAuthUI/Gate/ManaAuthGate.swift @@ -0,0 +1,130 @@ +import ManaCore +import Observation + +/// Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. +/// +/// Hintergrund: in `mana-swift-core` v1.2.0 wurde `AuthClient.Status` +/// um den anonymen `.guest`-Modus erweitert. Apps sollen ihre Inhalte +/// in diesem Modus möglichst vollständig zugänglich machen — nur +/// schreibende Server-Aktionen (KI-Generierung, Publish, Cross-Device- +/// Sync) sollen Login erzwingen. +/// +/// `ManaAuthGate` wrappt diese Logik so, dass sie aus Buttons heraus +/// einzeilig nutzbar bleibt: +/// +/// ```swift +/// @Environment(ManaAuthGate.self) private var gate +/// +/// Button("Karte mit KI generieren") { +/// gate.require { +/// // läuft erst, wenn .signedIn — sonst poppt das Login-Sheet, +/// // und die Aktion läuft nach erfolgreichem Sign-In. +/// await viewModel.generateCard() +/// } +/// } +/// ``` +/// +/// In der App-Root wird der Gate einmal aufgebaut und via Modifier +/// `manaAuthGate(_:signIn:)` an die View gehängt: +/// +/// ```swift +/// RootView() +/// .manaAuthGate(authGate) { +/// ManaLoginView( +/// auth: authClient, +/// onSignUpTapped: { /* push SignUp */ }, +/// onForgotTapped: { /* push Forgot */ } +/// ) +/// } +/// .environment(authGate) +/// ``` +/// +/// Verhalten: +/// - Status ist `.signedIn` → Action läuft sofort. +/// - Status ist `.guest`/`.signedOut`/`.error`/`.unknown` → Sign-In- +/// Sheet wird präsentiert, die Action gemerkt; nach Wechsel auf +/// `.signedIn` läuft die Action und das Sheet wird geschlossen. +/// - User schließt das Sheet ohne Login → Action wird verworfen. +@MainActor +@Observable +public final class ManaAuthGate { + public let auth: AuthClient + public var isPresentingSignIn: Bool = false + + /// Letzter aufgetretener Trigger-Grund. Diagnostik/Telemetrie: Apps + /// können hier ablesen, *was* den User in den Login-Flow geführt + /// hat (z.B. "KI-Generierung" vs. "Deck publish"), ohne dass das + /// in der Action selbst stehen muss. + public private(set) var lastReason: String? + + private var pending: PendingAction? + + public init(auth: AuthClient) { + self.auth = auth + } + + /// Führt `action` sofort aus, wenn der User eingeloggt ist. Sonst + /// wird das Sign-In-Sheet aufgeklappt und die Action wird ausgeführt, + /// sobald `auth.status` auf `.signedIn` wechselt. + /// + /// - Parameters: + /// - reason: Optional, kurzer technischer Kennzeichner für + /// Telemetrie/Logs. Wird der UI **nicht** angezeigt. + /// - action: Die zu schützende Aktion. Wird **nicht** ausgeführt, + /// wenn der User das Sheet ohne Login schließt. + public func require(reason: String? = nil, _ action: @escaping @MainActor () -> Void) { + lastReason = reason + if case .signedIn = auth.status { + action() + return + } + pending = .sync(action) + isPresentingSignIn = true + } + + /// Async-Variante von ``require(reason:_:)``. Die Action darf + /// `await`-Aufrufe enthalten; sie wird in einem neuen `Task` + /// gestartet. + public func require( + reason: String? = nil, + _ action: @escaping @MainActor () async -> Void + ) { + lastReason = reason + if case .signedIn = auth.status { + Task { await action() } + return + } + pending = .async(action) + isPresentingSignIn = true + } + + /// Wechselt der Auth-Status auf `.signedIn`, läuft die ausstehende + /// Action und das Sheet wird geschlossen. Vom ViewModifier + /// aufgerufen, kann aber auch manuell genutzt werden. + public func resolvePending() { + guard case .signedIn = auth.status else { return } + let next = pending + pending = nil + isPresentingSignIn = false + switch next { + case .none: + return + case let .sync(action): + action() + case let .async(action): + Task { await action() } + } + } + + /// Verwirft eine ausstehende Aktion. Vom ViewModifier nach + /// `sheet(onDismiss:)` aufgerufen — wenn der User das Sheet ohne + /// Login schließt, soll die Aktion *nicht* nachträglich laufen. + public func cancelPending() { + pending = nil + } + + private enum PendingAction { + case sync(@MainActor () -> Void) + case async(@MainActor () async -> Void) + } +} diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift new file mode 100644 index 0000000..be611ef --- /dev/null +++ b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift @@ -0,0 +1,63 @@ +import ManaCore +import SwiftUI + +/// ViewModifier, der das Sign-In-Sheet für einen ``ManaAuthGate`` +/// präsentiert. Die App liefert via `signIn`-Builder, welche Auth- +/// View im Sheet gezeigt wird (üblich: ``ManaLoginView``). +/// +/// Beobachtet `gate.auth.status` und schließt das Sheet automatisch +/// sobald der User eingeloggt ist; die ausstehende Aktion wird dann +/// gestartet. Wird das Sheet vorher dismisst, wird die Aktion verworfen. +public struct ManaAuthGateModifier: ViewModifier { + @Bindable private var gate: ManaAuthGate + private let signInContent: () -> SignInContent + + public init(gate: ManaAuthGate, @ViewBuilder signIn: @escaping () -> SignInContent) { + self.gate = gate + signInContent = signIn + } + + public func body(content: Content) -> some View { + content + .sheet( + isPresented: $gate.isPresentingSignIn, + onDismiss: { gate.cancelPending() } + ) { + signInContent() + .onChange(of: authStatusKey(gate.auth.status)) { _, _ in + gate.resolvePending() + } + } + } +} + +/// Reduziert `AuthClient.Status` auf einen `Equatable`-stabilen Key +/// für `onChange(of:)`. `Status` selbst ist `Equatable` — aber assoziierte +/// Werte (Email-String) sind hier irrelevant, wir wollen nur auf den +/// Übergang signedOut/guest → signedIn reagieren. +private func authStatusKey(_ status: AuthClient.Status) -> Int { + switch status { + case .unknown: 0 + case .signedOut: 1 + case .guest: 2 + case .signingIn: 3 + case .twoFactorRequired: 4 + case .signedIn: 5 + case .error: 6 + } +} + +public extension View { + /// Hängt einen ``ManaAuthGate`` an die View. Aufrufe von + /// `gate.require { ... }` führen entweder zur Aktion (signedIn) oder + /// zum Sign-In-Sheet, das der `signIn`-Builder liefert. + /// + /// Idiomatischer Einsatz: einmal am App-Root, danach den Gate via + /// `.environment(gate)` in den Sub-Views verfügbar machen. + func manaAuthGate( + _ gate: ManaAuthGate, + @ViewBuilder signIn: @escaping () -> some View + ) -> some View { + modifier(ManaAuthGateModifier(gate: gate, signIn: signIn)) + } +} diff --git a/Sources/ManaAuthUI/Login/LoginViewModel.swift b/Sources/ManaAuthUI/Login/LoginViewModel.swift index 8de3b9c..cec1bf6 100644 --- a/Sources/ManaAuthUI/Login/LoginViewModel.swift +++ b/Sources/ManaAuthUI/Login/LoginViewModel.swift @@ -12,13 +12,14 @@ public final class LoginViewModel { case idle case signingIn /// Sign-In ist gescheitert mit klassifiziertem Fehler. - /// `.emailNotVerified` ist ein wichtiger Sonderfall — die UI - /// schaltet darauf den Resend-Mail-Gate frei. case error(String) /// Sign-In ist gescheitert weil die Email noch nicht bestätigt /// ist. UI zeigt den Resend-Gate für die zuletzt eingegebene /// Email-Adresse. case emailNotVerified(email: String) + /// Sign-In war erfolgreich aber der Account hat 2FA aktiviert. + /// UI zeigt ``ManaTwoFactorChallengeView``. + case twoFactorRequired(email: String) } public var email: String = "" @@ -66,6 +67,13 @@ public final class LoginViewModel { case .signedIn: status = .idle password = "" // nicht im Memory lassen + case .twoFactorRequired: + // Sign-In war auf der ersten Stufe erfolgreich, jetzt + // braucht der User noch den 2FA-Code. Password aus dem + // Memory wischen — das ist verifiziert und wird nicht + // mehr gebraucht. + password = "" + status = .twoFactorRequired(email: trimmed) case .error: // Strukturierten Fehler aus AuthClient.lastError lesen statt // den String der Status-Maschine zu re-parsen. diff --git a/Sources/ManaAuthUI/Login/ManaLoginView.swift b/Sources/ManaAuthUI/Login/ManaLoginView.swift index 5632834..cb4276a 100644 --- a/Sources/ManaAuthUI/Login/ManaLoginView.swift +++ b/Sources/ManaAuthUI/Login/ManaLoginView.swift @@ -48,6 +48,17 @@ public struct ManaLoginView: View { auth: auth, onBackToLogin: { model.resetToIdle() } ) + case .twoFactorRequired: + ManaTwoFactorChallengeView( + auth: auth, + onCancel: { + // Abbruch: User will zurück zum Email/Password-Form. + // AuthClient.status zurücksetzen damit der Challenge- + // Token verworfen wird; UI-Status auf idle. + Task { await auth.signOut(keepGuestMode: true) } + model.resetToIdle() + } + ) default: loginForm } diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift new file mode 100644 index 0000000..e925cb6 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift @@ -0,0 +1,310 @@ +import ManaCore +import Observation +import SwiftUI + +/// Account-Section-Block für die 2FA-Verwaltung. Apps bauen den +/// einfach in ihre AccountView ein: +/// +/// ```swift +/// ManaTwoFactorAccountRow(auth: auth) +/// .manaBrand(brand) +/// ``` +/// +/// Die Row holt den 2FA-Status beim ersten Erscheinen via +/// `AuthClient.getProfile()` und zeigt dann entweder: +/// - "Zwei-Faktor aktivieren" (Enroll-Sheet) bei `twoFactorEnabled == false` +/// - "Zwei-Faktor deaktivieren" + "Backup-Codes erneuern" bei `true` +/// +/// Nach Enroll/Disable wird der Status automatisch neu geladen, +/// damit die Row sich konsistent updated. +@MainActor +@Observable +final class TwoFactorAccountRowModel { + enum LoadState: Equatable { + case loading + case loaded(twoFactorEnabled: Bool) + case error(String) + } + + private(set) var state: LoadState = .loading + private let auth: AuthClient + + init(auth: AuthClient) { + self.auth = auth + } + + func reload() async { + state = .loading + do { + let profile = try await auth.getProfile() + state = .loaded(twoFactorEnabled: profile.twoFactorEnabled) + } catch let error as AuthError { + if case .notSignedIn = error { + state = .error("Nicht angemeldet") + } else { + state = .error(error.errorDescription ?? "Status konnte nicht geladen werden") + } + } catch { + state = .error(String(describing: error)) + } + } +} + +public struct ManaTwoFactorAccountRow: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorAccountRowModel + @State private var showEnroll = false + @State private var showDisable = false + @State private var showRegenerate = false + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + _model = State(initialValue: TwoFactorAccountRowModel(auth: auth)) + } + + public var body: some View { + Group { + switch model.state { + case .loading: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("2FA-Status lädt…") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + case let .loaded(enabled): + if enabled { + enabledRow + } else { + disabledRow + } + case let .error(message): + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + } + } + .task { + await model.reload() + } + .sheet(isPresented: $showEnroll, onDismiss: { + Task { await model.reload() } + }) { + ManaTwoFactorEnrollView(auth: auth, onDone: { showEnroll = false }) + .manaBrand(brand) + } + .sheet(isPresented: $showDisable, onDismiss: { + Task { await model.reload() } + }) { + ManaTwoFactorDisableView(auth: auth, onDone: { showDisable = false }) + .manaBrand(brand) + } + .sheet(isPresented: $showRegenerate, onDismiss: { + Task { await model.reload() } + }) { + ManaBackupCodeRegenerateView(auth: auth, onDone: { showRegenerate = false }) + .manaBrand(brand) + } + } + + @ViewBuilder + private var disabledRow: some View { + Button(action: { showEnroll = true }) { + HStack { + Image(systemName: "lock.shield") + .foregroundStyle(brand.mutedForeground) + VStack(alignment: .leading, spacing: 2) { + Text("Zwei-Faktor aktivieren") + .foregroundStyle(brand.foreground) + Text("TOTP-App mit Backup-Codes") + .font(.caption) + .foregroundStyle(brand.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(brand.mutedForeground) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var enabledRow: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "checkmark.shield.fill") + .foregroundStyle(brand.success) + Text("Zwei-Faktor aktiv") + .foregroundStyle(brand.foreground) + Spacer() + } + + Button(action: { showRegenerate = true }) { + Text("Backup-Codes erneuern") + .font(.subheadline) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + Button(role: .destructive, action: { showDisable = true }) { + Text("Zwei-Faktor deaktivieren") + .font(.subheadline) + .foregroundStyle(brand.error) + } + .buttonStyle(.plain) + } + } + +} + +/// Sheet zum Erneuern der Backup-Codes. Re-Auth via Passwort, +/// zeigt danach die neuen Codes. +public struct ManaBackupCodeRegenerateView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var newCodes: [String] = [] + @State private var status: Status = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum Status: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Backup-Codes erneuern") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Die alten Codes werden ungültig. Bestätige mit deinem Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "Neue Codes generieren", + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Neue Codes generiert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Codes JETZT. Alte Codes sind ungültig.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(newCodes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(newCodes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + private func submit() async { + status = .working + do { + newCodes = try await auth.regenerateBackupCodes(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Erneuerung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift new file mode 100644 index 0000000..67f8bd7 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift @@ -0,0 +1,101 @@ +import ManaCore +import SwiftUI + +/// Wird angezeigt, wenn nach erfolgreichem Email/PW-`signIn` der +/// `AuthClient.status` auf ``AuthClient/Status/twoFactorRequired(token:methods:email:)`` +/// gewechselt ist. Bietet TOTP-Code-Eingabe (6-stellig) plus einen +/// Fallback auf Backup-Codes. +/// +/// Apps müssen das selbst nicht einbauen — ``ManaLoginView`` schaltet +/// automatisch um. Nur direkt nötig wenn die App eine eigene Login- +/// UI-Maschine hat (z.B. Memoros AccountView). +public struct ManaTwoFactorChallengeView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorChallengeViewModel + private let onCancel: () -> Void + + /// - Parameters: + /// - auth: gemeinsamer `AuthClient` der App (Status muss bereits + /// `.twoFactorRequired` sein). + /// - onCancel: Callback wenn der User "Abbrechen" drückt. Apps + /// setzen den AuthClient typischerweise auf `.signedOut` + /// zurück und zeigen wieder die Login-View. + public init( + auth: AuthClient, + onCancel: @escaping () -> Void + ) { + _model = State(initialValue: TwoFactorChallengeViewModel(auth: auth)) + self.onCancel = onCancel + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + VStack(spacing: 20) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text(model.mode == .totp ? "Zwei-Faktor-Code" : "Backup-Code") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text(promptText) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaTextField(placeholderText, text: $model.code) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(model.mode == .totp ? .numberPad : .asciiCapable) + .textInputAutocapitalization(model.mode == .totp ? .never : .characters) + #endif + + ManaPrimaryButton( + "Bestätigen", + isLoading: model.isVerifying, + isEnabled: model.canSubmit + ) { + Task { await model.submit() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button(action: { model.toggleMode() }) { + Text(model.mode == .totp + ? "Stattdessen Backup-Code verwenden" + : "Stattdessen 6-stelligen Code verwenden" + ) + .font(.footnote) + .foregroundStyle(brand.primary) + } + .padding(.top, 12) + + Button("Abbrechen", action: onCancel) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 8) + } + } + } + + private var promptText: String { + switch model.mode { + case .totp: + "Öffne deine Authenticator-App und gib den 6-stelligen Code für deinen Account ein." + case .backupCode: + "Gib einen deiner einmal-nutzbaren Backup-Codes ein. Jeder Code lässt sich nur einmal verwenden." + } + } + + private var placeholderText: String { + model.mode == .totp ? "123 456" : "xxxx-xxxx" + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift new file mode 100644 index 0000000..789ba62 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift @@ -0,0 +1,335 @@ +import CoreImage.CIFilterBuiltins +import ManaCore +import SwiftUI + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Account-Sheet: TOTP-2FA aktivieren. 3-Phasen-Wizard. +/// +/// 1. Passwort eingeben (Re-Auth) +/// 2. QR-Code mit Authenticator-App scannen + Test-Code eingeben +/// 3. Backup-Codes anzeigen und vom User bestätigen lassen +public struct ManaTwoFactorEnrollView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorEnrollmentViewModel + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + _model = State(initialValue: TwoFactorEnrollmentViewModel(auth: auth)) + self.onDone = onDone + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch model.phase { + case .password: + passwordPhase + case let .verify(uri, _): + verifyPhase(uri: uri) + case let .backupCodes(codes): + backupCodesPhase(codes: codes) + } + } + } + + // MARK: - Phase 1: Password + + @ViewBuilder + private var passwordPhase: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Zwei-Faktor aktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Schütze deinen Account mit einem zusätzlichen Code. Bestätige dazu erst dein Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField( + "Passwort", + text: $model.password, + textContentType: .password + ) + + ManaPrimaryButton( + "Weiter", + isLoading: model.isWorking, + isEnabled: model.canSubmitPassword + ) { + Task { await model.enrollWithPassword() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 2: QR + Verify + + @ViewBuilder + private func verifyPhase(uri: String) -> some View { + VStack(spacing: 16) { + Text("Code scannen") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Öffne deine Authenticator-App (z.B. 1Password, Aegis, Google Authenticator) und scanne diesen QR-Code.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + qrCode(for: uri) + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) + + Text("Gib zur Bestätigung den 6-stelligen Code aus der App ein:") + .font(.subheadline) + .foregroundStyle(brand.foreground) + .multilineTextAlignment(.center) + .padding(.top, 8) + + ManaTextField("123 456", text: $model.verifyCode) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + #endif + + ManaPrimaryButton( + "Weiter zu Backup-Codes", + isEnabled: model.canSubmitVerify + ) { + model.confirmVerify() + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 3: Backup-Codes + + @ViewBuilder + private func backupCodesPhase(codes: [String]) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Zwei-Faktor aktiv") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Backup-Codes JETZT. Du brauchst sie wenn du dein Authenticator-Gerät verlierst. Jeder Code lässt sich nur einmal verwenden.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(codes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(codes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + // MARK: - QR-Code + + /// Rendert eine `otpauth://`-URI als QR-Code via `CoreImage`. Auf + /// iOS/macOS sind `CIFilter.qrCodeGenerator()` system-bordmittel. + @ViewBuilder + private func qrCode(for content: String) -> some View { + if let cgImage = makeQRCode(from: content) { + #if canImport(UIKit) + Image(uiImage: UIImage(cgImage: cgImage)) + .interpolation(.none) + .resizable() + .scaledToFit() + #elseif canImport(AppKit) + Image(nsImage: NSImage(cgImage: cgImage, size: NSSize(width: 220, height: 220))) + .interpolation(.none) + .resizable() + .scaledToFit() + #else + Text(content) + .font(.system(.caption, design: .monospaced)) + #endif + } else { + Text("QR-Code konnte nicht generiert werden — bitte URI manuell kopieren:\n\(content)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(brand.mutedForeground) + } + } + + private func makeQRCode(from string: String) -> CGImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + // Upscale damit der QR-Code scharf bleibt (kein anti-aliasing). + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaled = output.transformed(by: transform) + return context.createCGImage(scaled, from: scaled.extent) + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +/// Account-Sheet: TOTP-2FA wieder deaktivieren. Einfacher Single-Step +/// mit Passwort-Re-Auth. +public struct ManaTwoFactorDisableView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var status: DisableStatus = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum DisableStatus: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Dein Account wird wieder nur mit Email + Passwort geschützt. Backup-Codes verlieren ihre Gültigkeit.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "2FA deaktivieren", + role: .destructive, + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.open") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktiviert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + ManaPrimaryButton("Fertig") { onDone() } + .padding(.top, 16) + } + } + + private func submit() async { + guard !password.isEmpty else { return } + status = .working + do { + try await auth.disableTotp(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Deaktivieren fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift new file mode 100644 index 0000000..76fa344 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift @@ -0,0 +1,83 @@ +import Foundation +import ManaCore +import Observation + +/// State-Maschine für ``ManaTwoFactorChallengeView``. Setzt auf den +/// `.twoFactorRequired`-Zustand des `AuthClient` auf, der nach einem +/// erfolgreichen Email/PW-`signIn` mit 2FA-aktiviertem Account +/// gesetzt wird. +@MainActor +@Observable +public final class TwoFactorChallengeViewModel { + public enum Mode: Equatable, Sendable { + case totp + case backupCode + } + + public enum Status: Equatable, Sendable { + case idle + case verifying + case error(String) + } + + public var mode: Mode = .totp + public var code: String = "" + public var trustDevice: Bool = false + public private(set) var status: Status = .idle + + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + } + + public var canSubmit: Bool { + guard !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if case .verifying = status { return false } + switch mode { + case .totp: + // TOTP: 6 Ziffern (Better-Auth-Default) + let digitsOnly = code.filter { $0.isNumber } + return digitsOnly.count == 6 + case .backupCode: + // Backup-Codes: ~10 Zeichen alphanumerisch + Trenner. + // Pragmatik: nicht-leer reicht — Server validiert exakt. + return !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + public var isVerifying: Bool { + if case .verifying = status { return true } + return false + } + + public func toggleMode() { + mode = mode == .totp ? .backupCode : .totp + code = "" + status = .idle + } + + public func submit() async { + let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + + status = .verifying + do { + switch mode { + case .totp: + try await auth.verifyTotp(code: cleaned, trustDevice: trustDevice) + case .backupCode: + try await auth.verifyBackupCode(code: cleaned, trustDevice: trustDevice) + } + // Bei Erfolg: Status bleibt .verifying — die View beobachtet + // den AuthClient.status (.signedIn) und reagiert über den + // umgebenden Gate/Root-View. Code aus dem Memory wischen. + code = "" + status = .idle + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Verifikation fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift new file mode 100644 index 0000000..00809e0 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift @@ -0,0 +1,107 @@ +import Foundation +import ManaCore +import Observation + +/// State-Maschine für ``ManaTwoFactorEnrollView``. 3-Phasen-Wizard: +/// +/// 1. **Re-Auth** — User gibt aktuelles Passwort ein +/// 2. **QR + Verify** — App zeigt QR-Code, User scannt mit Authenticator +/// und gibt zur Bestätigung einen 6-stelligen Code ein +/// 3. **Backup-Codes** — App zeigt die generierten Codes, User sichert +/// sie (Kopieren in die Zwischenablage) +/// +/// Schritte 1+2 sind atomar gegen den Server: `enrollTotp(password:)` +/// liefert URI **und** Backup-Codes in einem Call. Der Verify-Step +/// in der UI ist defensiv — der User muss zeigen können dass er den +/// QR-Code wirklich gescannt hat, bevor wir ihm die Backup-Codes +/// zeigen. Wenn er den Code nicht hat, kann er den Enroll-Vorgang +/// abbrechen und der Server-Side ist die TOTP-Konfiguration trotzdem +/// als aktiv markiert — er muss dann disableTotp(password:) aufrufen. +@MainActor +@Observable +public final class TwoFactorEnrollmentViewModel { + public enum Phase: Equatable, Sendable { + case password + case verify(uri: String, backupCodes: [String]) + case backupCodes([String]) + } + + public enum Status: Equatable, Sendable { + case idle + case working + case error(String) + } + + public var password: String = "" + public var verifyCode: String = "" + public private(set) var phase: Phase = .password + public private(set) var status: Status = .idle + + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + } + + // MARK: - Phase 1: Password + + public var canSubmitPassword: Bool { + guard !password.isEmpty else { return false } + if case .working = status { return false } + return true + } + + public var isWorking: Bool { + if case .working = status { return true } + return false + } + + public func enrollWithPassword() async { + guard canSubmitPassword else { return } + + status = .working + do { + let enrollment = try await auth.enrollTotp(password: password) + password = "" + phase = .verify(uri: enrollment.totpURI, backupCodes: enrollment.backupCodes) + status = .idle + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Aktivierung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + // MARK: - Phase 2: Verify + + public var canSubmitVerify: Bool { + let digits = verifyCode.filter { $0.isNumber } + return digits.count == 6 && !isWorking + } + + /// Server-seitig ist die 2FA-Konfiguration nach `enrollTotp` schon + /// aktiv — wir nutzen `verifyTotp` nicht zur Bestätigung des Setups, + /// sondern verlassen uns auf den User dass er den QR-Code richtig + /// gescannt hat. Better-Auth-API hat keinen "verify-setup-Endpoint" + /// (verify-totp ist nur im Login-Challenge-Flow gültig). Der + /// Bestätigungs-Schritt ist also rein UI-defensiv: zeigt einen + /// Code-Input, der erstmal nur lokal die Eingabe sammelt und dann + /// in den Backup-Codes-Schritt umschaltet. + public func confirmVerify() { + if case let .verify(_, codes) = phase { + verifyCode = "" + phase = .backupCodes(codes) + status = .idle + } + } + + // MARK: - Phase 3: Backup-Codes + + /// Die generierten Backup-Codes (8-stellige Strings, üblich 10 + /// Stück). UI zeigt sie zum Kopieren/Sichern. + public var backupCodes: [String] { + if case let .backupCodes(codes) = phase { return codes } + if case let .verify(_, codes) = phase { return codes } + return [] + } +} diff --git a/Sources/ManaWebShell/WebNavState.swift b/Sources/ManaWebShell/WebNavState.swift new file mode 100644 index 0000000..c467daf --- /dev/null +++ b/Sources/ManaWebShell/WebNavState.swift @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..2570d0f --- /dev/null +++ b/Sources/ManaWebShell/WebShellConfig.swift @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..ab349cb --- /dev/null +++ b/Sources/ManaWebShell/WebShellCoordinator.swift @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..3291b89 --- /dev/null +++ b/Sources/ManaWebShell/WebShellScripts.swift @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..98cc341 --- /dev/null +++ b/Sources/ManaWebShell/WebShellView.swift @@ -0,0 +1,183 @@ +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 new file mode 100644 index 0000000..682525d --- /dev/null +++ b/Sources/ManaWebShell/WebTarget.swift @@ -0,0 +1,14 @@ +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/ManaAuthGateTests.swift b/Tests/ManaAuthUITests/ManaAuthGateTests.swift new file mode 100644 index 0000000..4c17059 --- /dev/null +++ b/Tests/ManaAuthUITests/ManaAuthGateTests.swift @@ -0,0 +1,125 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("ManaAuthGate") +@MainActor +struct ManaAuthGateTests { + @Test("require mit .signedIn führt Action sofort aus, kein Sheet") + func runsImmediatelyWhenSignedIn() async throws { + let mocked = makeMockedAuth() + await signInMockedAuth(mocked) + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + + #expect(didRun) + #expect(!gate.isPresentingSignIn) + } + + @Test("require mit .signedOut öffnet Sheet, Action wartet") + func defersWhenSignedOut() { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() // → .signedOut + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + + #expect(!didRun) + #expect(gate.isPresentingSignIn) + } + + @Test("require mit .guest öffnet Sheet, Action wartet") + func defersWhenGuest() throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + + #expect(!didRun) + #expect(gate.isPresentingSignIn) + } + + @Test("resolvePending läuft, sobald Status auf .signedIn wechselt") + func resolvesPendingAfterSignIn() async throws { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + #expect(!didRun) + #expect(gate.isPresentingSignIn) + + await signInMockedAuth(mocked) + gate.resolvePending() + + #expect(didRun) + #expect(!gate.isPresentingSignIn) + } + + @Test("resolvePending ist no-op wenn noch nicht signedIn") + func resolvePendingNoOpWhenNotSignedIn() { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + gate.resolvePending() + + #expect(!didRun) + #expect(gate.isPresentingSignIn) + } + + @Test("cancelPending verwirft Action — danach kein Lauf bei resolvePending") + func cancelDiscardsPending() async throws { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + gate.cancelPending() + + await signInMockedAuth(mocked) + gate.resolvePending() + + #expect(!didRun) + } + + @Test("lastReason wird gesetzt") + func lastReasonIsRecorded() { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + gate.require(reason: "ai-generate") {} + #expect(gate.lastReason == "ai-generate") + } + + // Die async-Overload (`Task { await action() }`) ist trivial und + // ein dedizierter Test über `Task.sleep` ist timing-fragil. + // Die sync-Variante prüft die State-Maschine vollständig; die + // async-Variante teilt sich Pending/Resolve-Logik mit der sync- + // Variante (siehe ManaAuthGate.swift). +} + +/// Loggt den `MockedAuth` über den echten signIn-Pfad ein. Wird genutzt +/// statt direktem `persistSession`, weil letzteres `internal` zu ManaCore +/// ist und aus den ui-Tests nicht erreichbar. +@MainActor +func signInMockedAuth(_ mocked: MockedAuth, email: String = "u@x.de") async { + // Gültiger HS256-Header.payload (exp 2_000_000_000) — JWT.expiry() + // läuft nicht in den Refresh-Pfad. + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8)) + } + await mocked.auth.signIn(email: email, password: "Aa-123456789") +} diff --git a/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift new file mode 100644 index 0000000..7778b25 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift @@ -0,0 +1,118 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorChallengeViewModel") +@MainActor +struct TwoFactorChallengeViewModelTests { + /// Bringt den AuthClient in den `.twoFactorRequired`-Status. + private func challengedAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + mocked.setHandler { _ in + (200, Data(#""" + {"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-x"} + """#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("canSubmit fordert 6-stellige Ziffern im TOTP-Modus") + func canSubmitTotpDigits() { + let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth) + model.code = "" + #expect(model.canSubmit == false) + model.code = "12345" + #expect(model.canSubmit == false) // 5 Ziffern + model.code = "123456" + #expect(model.canSubmit == true) + model.code = "123 456" // erlaubt Whitespace → 6 Ziffern + #expect(model.canSubmit == true) + model.code = "abcdef" + #expect(model.canSubmit == false) + } + + @Test("canSubmit im Backup-Modus akzeptiert nicht-leere Strings") + func canSubmitBackupCode() { + let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth) + model.toggleMode() + #expect(model.mode == .backupCode) + model.code = "" + #expect(model.canSubmit == false) + model.code = "abc-def-ghi" + #expect(model.canSubmit == true) + } + + @Test("toggleMode wechselt mode + cleared code") + func toggleModeClearsCode() { + let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth) + model.code = "123456" + model.toggleMode() + #expect(model.mode == .backupCode) + #expect(model.code == "") + } + + @Test("submit mit erfolgreichem TOTP setzt AuthClient auf signedIn") + func submitTotpSuccess() async { + let mocked = await challengedAuth() + let model = TwoFactorChallengeViewModel(auth: mocked.auth) + model.code = "123456" + + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { request in + #expect(request.url?.path == "/api/v1/auth/two-factor/verify-totp") + return (200, Data(#""" + {"success":true,"accessToken":"\#(access)","refreshToken":"r1"} + """#.utf8)) + } + + await model.submit() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + #expect(model.code == "") + #expect(model.status == .idle) + } + + @Test("submit mit falschem TOTP → .error, AuthClient bleibt twoFactorRequired") + func submitTotpWrongCode() async { + let mocked = await challengedAuth() + let model = TwoFactorChallengeViewModel(auth: mocked.auth) + model.code = "000000" + + mocked.setHandler { _ in + (401, Data(#"{"error":"TWO_FACTOR_FAILED","status":401}"#.utf8)) + } + + await model.submit() + if case let .error(message) = model.status { + #expect(message == "Zwei-Faktor-Code falsch.") + } else { + Issue.record("Expected .error, got \(model.status)") + } + // AuthClient bleibt im challenge-Status, User kann retry + if case .twoFactorRequired = mocked.auth.status { + #expect(Bool(true)) + } else { + Issue.record("Expected .twoFactorRequired, got \(mocked.auth.status)") + } + } + + @Test("submit im Backup-Modus ruft verify-backup-code-Endpoint") + func submitBackupCodeRoutesCorrectly() async { + let mocked = await challengedAuth() + let model = TwoFactorChallengeViewModel(auth: mocked.auth) + model.toggleMode() + model.code = "abc-def-ghi" + + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { request in + #expect(request.url?.path == "/api/v1/auth/two-factor/verify-backup-code") + return (200, Data(#""" + {"success":true,"accessToken":"\#(access)","refreshToken":"r1"} + """#.utf8)) + } + + await model.submit() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift new file mode 100644 index 0000000..1c22ac6 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift @@ -0,0 +1,130 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorEnrollmentViewModel") +@MainActor +struct TwoFactorEnrollmentViewModelTests { + private func signedInAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("enrollWithPassword erfolgreich → phase wechselt auf verify") + func enrollSuccess() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/Mana:u@x.de?secret=ABC","backupCodes":["a","b","c"]} + """#.utf8)) + } + + await model.enrollWithPassword() + if case let .verify(uri, codes) = model.phase { + #expect(uri.hasPrefix("otpauth://totp/")) + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .verify, got \(model.phase)") + } + #expect(model.password == "") // out of memory + } + + @Test("enrollWithPassword falsches PW → .error, phase bleibt password") + func enrollWrongPassword() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "wrong" + + mocked.setHandler { _ in + (401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8)) + } + + await model.enrollWithPassword() + if case .password = model.phase { + #expect(Bool(true)) + } else { + Issue.record("Expected .password, got \(model.phase)") + } + if case let .error(message) = model.status { + #expect(message == "Email oder Passwort falsch") + } else { + Issue.record("Expected .error, got \(model.status)") + } + } + + @Test("canSubmitVerify fordert 6 Ziffern") + func canSubmitVerify() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a"]} + """#.utf8)) + } + await model.enrollWithPassword() + + model.verifyCode = "" + #expect(model.canSubmitVerify == false) + model.verifyCode = "12345" + #expect(model.canSubmitVerify == false) + model.verifyCode = "123456" + #expect(model.canSubmitVerify == true) + model.verifyCode = "abcdef" + #expect(model.canSubmitVerify == false) + } + + @Test("confirmVerify wechselt von verify auf backupCodes") + func confirmVerifySwitchesPhase() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a","b","c"]} + """#.utf8)) + } + await model.enrollWithPassword() + model.verifyCode = "123456" + + model.confirmVerify() + if case let .backupCodes(codes) = model.phase { + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .backupCodes, got \(model.phase)") + } + #expect(model.verifyCode == "") + } + + @Test("backupCodes computed property returnt Codes aus verify- und backupCodes-Phase") + func backupCodesAccessor() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + // Phase .password → keine Codes + #expect(model.backupCodes == []) + + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["c1","c2"]} + """#.utf8)) + } + await model.enrollWithPassword() + // Phase .verify → Codes verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + + model.verifyCode = "123456" + model.confirmVerify() + // Phase .backupCodes → Codes weiter verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + } +} diff --git a/Tests/ManaWebShellTests/WebShellConfigTests.swift b/Tests/ManaWebShellTests/WebShellConfigTests.swift new file mode 100644 index 0000000..46bddcb --- /dev/null +++ b/Tests/ManaWebShellTests/WebShellConfigTests.swift @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..81727ad --- /dev/null +++ b/devlog/2026-05-13/data.json @@ -0,0 +1,126 @@ +{ + "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 new file mode 100644 index 0000000..9042a76 --- /dev/null +++ b/devlog/2026-05-13/macher.md @@ -0,0 +1,79 @@ +--- +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 new file mode 100644 index 0000000..10ed6e2 --- /dev/null +++ b/devlog/2026-05-13/spieler.md @@ -0,0 +1,35 @@ +--- +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.