v0.2.0 — ManaAuthGate für Action-Level-Login-Eskalation

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 22:16:27 +02:00
parent 0a2cb349b4
commit 6417b4cd33
4 changed files with 357 additions and 0 deletions

View file

@ -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)
}
}

View file

@ -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<SignInContent: View>: 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))
}
}