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:
parent
0a2cb349b4
commit
02080df678
4 changed files with 357 additions and 0 deletions
40
CHANGELOG.md
40
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
|
||||
|
|
|
|||
130
Sources/ManaAuthUI/Gate/ManaAuthGate.swift
Normal file
130
Sources/ManaAuthUI/Gate/ManaAuthGate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
62
Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift
Normal file
62
Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
125
Tests/ManaAuthUITests/ManaAuthGateTests.swift
Normal file
125
Tests/ManaAuthUITests/ManaAuthGateTests.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue