From 6417b4cd337ab59d3134061c130cd7a17d6bf093 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 22:16:27 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20ManaAuthGate=20f=C3=BCr=20?= =?UTF-8?q?Action-Level-Login-Eskalation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps mit Guest-/Login-optional-Modus brauchen kein Full-Screen- Login-Gate mehr. Aktionen, die einen Account brauchen, werden in gate.require { ... } gewrappt — Login wird zur Inline-Eskalation, nicht zum App-Block. ManaAuthGate — @Observable-State-Maschine: - require(reason:work:) sync und async Overloads - Bei .signedIn: Aktion läuft sofort - Bei .signedOut/.guest: Sign-In-Sheet öffnet, Aktion gemerkt; nach erfolgreichem Sign-In läuft sie automatisch - lastReason als optionaler Telemetrie-Hint ManaAuthGateModifier / View.manaAuthGate(_:signIn:) — hängt das Sign-In-Sheet an einen Root-View, beobachtet auth.status. Wechsel auf .signedIn schließt das Sheet + resolved pending; manuelles Dismiss verwirft. Konvention für native Apps: 1. bootstrap() → bei .signedOut → enterGuestMode() 2. Root-View zeigt immer App-Inhalte, nie eine Vollbild-Login-Wall 3. Account-Aktionen via gate.require { ... } Memoro hat das informell schon. Cards komplett umgestellt (siehe cards-native commit). Manaspur folgt. 7 neue Tests: sofortiger Run bei .signedIn, Defer bei .signedOut/ .guest, resolvePending, cancelPending, lastReason-Tracking. Setzt mana-swift-core ≥ 1.2.0 voraus. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 40 ++++++ Sources/ManaAuthUI/Gate/ManaAuthGate.swift | 130 ++++++++++++++++++ .../Gate/ManaAuthGateModifier.swift | 62 +++++++++ Tests/ManaAuthUITests/ManaAuthGateTests.swift | 125 +++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 Sources/ManaAuthUI/Gate/ManaAuthGate.swift create mode 100644 Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift create mode 100644 Tests/ManaAuthUITests/ManaAuthGateTests.swift 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") +}