diff --git a/CHANGELOG.md b/CHANGELOG.md index f95fbc5..72daa99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.2.0] — 2026-05-13 + +Minor — Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. +Komplett additiv; braucht `mana-swift-core` ≥ 1.2.0. + +### ManaAuthUI — neu: ManaAuthGate + +- `ManaAuthGate` — `@Observable`-State-Maschine, die eine Aktion erst + laufen lässt, wenn der User eingeloggt ist. Wenn nicht, wird das + Sign-In-Sheet aufgeklappt und die Aktion gemerkt; nach erfolgreichem + Sign-In läuft sie automatisch. +- Zwei `require`-Overloads: synchron (`() -> Void`) und async + (`() async -> Void`). Konsumenten schreiben `gate.require { ... }` + ohne sich um das Gate-Lifecycle zu kümmern. +- `ManaAuthGateModifier` / `View.manaAuthGate(_:signIn:)` — hängt das + Sign-In-Sheet an einen Root-View und beobachtet `auth.status`. + Wechsel auf `.signedIn` schließt das Sheet und löst die Pending- + Aktion aus; manuelles Dismiss verwirft die Pending-Aktion. +- `lastReason` als optionaler Telemetrie-Hint pro `require`-Call. + +### Konvention + +Native-Apps sollen `mana-swift-core` v1.2.0 + Guest-Mode + diesen Gate +als Standardweg nutzen. Pattern für Cards/Manaspur/Memoro: + +1. Beim App-Start: `bootstrap()`, dann bei `.signedOut` → `enterGuestMode()`. +2. Root-View zeigt immer App-Inhalte; **nie** eine Vollbild-Login-Wall. +3. Aktionen, die einen Account brauchen, werden in + `gate.require { ... }` gewrappt — Login wird zur Inline-Eskalation + statt zum App-Block. + +Memoro hat dieses Muster informell schon umgesetzt (ContentView ohne +Hard-Gate). Cards + Manaspur ziehen mit ihren nächsten Releases nach. + +### Tests + +- 7 neue Tests für `ManaAuthGate`: sofortiger Run bei `.signedIn`, + Defer bei `.signedOut`/`.guest`, `resolvePending` nach Sign-In, + `cancelPending`, `lastReason`-Tracking. + ## [0.1.0] — 2026-05-13 Phase 2 aus dem Native-Auth-Vollausbau-Plan (Option A, siehe diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGate.swift b/Sources/ManaAuthUI/Gate/ManaAuthGate.swift new file mode 100644 index 0000000..4ea9580 --- /dev/null +++ b/Sources/ManaAuthUI/Gate/ManaAuthGate.swift @@ -0,0 +1,130 @@ +import ManaCore +import Observation + +/// Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. +/// +/// Hintergrund: in `mana-swift-core` v1.2.0 wurde `AuthClient.Status` +/// um den anonymen `.guest`-Modus erweitert. Apps sollen ihre Inhalte +/// in diesem Modus möglichst vollständig zugänglich machen — nur +/// schreibende Server-Aktionen (KI-Generierung, Publish, Cross-Device- +/// Sync) sollen Login erzwingen. +/// +/// `ManaAuthGate` wrappt diese Logik so, dass sie aus Buttons heraus +/// einzeilig nutzbar bleibt: +/// +/// ```swift +/// @Environment(ManaAuthGate.self) private var gate +/// +/// Button("Karte mit KI generieren") { +/// gate.require { +/// // läuft erst, wenn .signedIn — sonst poppt das Login-Sheet, +/// // und die Aktion läuft nach erfolgreichem Sign-In. +/// await viewModel.generateCard() +/// } +/// } +/// ``` +/// +/// In der App-Root wird der Gate einmal aufgebaut und via Modifier +/// `manaAuthGate(_:signIn:)` an die View gehängt: +/// +/// ```swift +/// RootView() +/// .manaAuthGate(authGate) { +/// ManaLoginView( +/// auth: authClient, +/// onSignUpTapped: { /* push SignUp */ }, +/// onForgotTapped: { /* push Forgot */ } +/// ) +/// } +/// .environment(authGate) +/// ``` +/// +/// Verhalten: +/// - Status ist `.signedIn` → Action läuft sofort. +/// - Status ist `.guest`/`.signedOut`/`.error`/`.unknown` → Sign-In- +/// Sheet wird präsentiert, die Action gemerkt; nach Wechsel auf +/// `.signedIn` läuft die Action und das Sheet wird geschlossen. +/// - User schließt das Sheet ohne Login → Action wird verworfen. +@MainActor +@Observable +public final class ManaAuthGate { + public let auth: AuthClient + public var isPresentingSignIn: Bool = false + + /// Letzter aufgetretener Trigger-Grund. Diagnostik/Telemetrie: Apps + /// können hier ablesen, *was* den User in den Login-Flow geführt + /// hat (z.B. "KI-Generierung" vs. "Deck publish"), ohne dass das + /// in der Action selbst stehen muss. + public private(set) var lastReason: String? + + private var pending: PendingAction? + + public init(auth: AuthClient) { + self.auth = auth + } + + /// Führt `action` sofort aus, wenn der User eingeloggt ist. Sonst + /// wird das Sign-In-Sheet aufgeklappt und die Action wird ausgeführt, + /// sobald `auth.status` auf `.signedIn` wechselt. + /// + /// - Parameters: + /// - reason: Optional, kurzer technischer Kennzeichner für + /// Telemetrie/Logs. Wird der UI **nicht** angezeigt. + /// - action: Die zu schützende Aktion. Wird **nicht** ausgeführt, + /// wenn der User das Sheet ohne Login schließt. + public func require(reason: String? = nil, _ action: @escaping @MainActor () -> Void) { + lastReason = reason + if case .signedIn = auth.status { + action() + return + } + pending = .sync(action) + isPresentingSignIn = true + } + + /// Async-Variante von ``require(reason:_:)``. Die Action darf + /// `await`-Aufrufe enthalten; sie wird in einem neuen `Task` + /// gestartet. + public func require( + reason: String? = nil, + _ action: @escaping @MainActor () async -> Void + ) { + lastReason = reason + if case .signedIn = auth.status { + Task { await action() } + return + } + pending = .async(action) + isPresentingSignIn = true + } + + /// Wechselt der Auth-Status auf `.signedIn`, läuft die ausstehende + /// Action und das Sheet wird geschlossen. Vom ViewModifier + /// aufgerufen, kann aber auch manuell genutzt werden. + public func resolvePending() { + guard case .signedIn = auth.status else { return } + let next = pending + pending = nil + isPresentingSignIn = false + switch next { + case .none: + return + case let .sync(action): + action() + case let .async(action): + Task { await action() } + } + } + + /// Verwirft eine ausstehende Aktion. Vom ViewModifier nach + /// `sheet(onDismiss:)` aufgerufen — wenn der User das Sheet ohne + /// Login schließt, soll die Aktion *nicht* nachträglich laufen. + public func cancelPending() { + pending = nil + } + + private enum PendingAction { + case sync(@MainActor () -> Void) + case async(@MainActor () async -> Void) + } +} diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift new file mode 100644 index 0000000..6d92a41 --- /dev/null +++ b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift @@ -0,0 +1,62 @@ +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 .signedIn: 4 + case .error: 5 + } +} + +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/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") +}