feat(auth): Guest-Mode + Login-optionale Surface

RootView ohne Hard-Login-Gate — TabBar zeigt sich immer, beim Start
wechselt App bei .signedOut automatisch in den anonymen .guest-Modus
(mana-swift-core v1.2.0). Auth-Sheets (Login, SignUp, Forgot, Reset)
hängen jetzt als ManaAuthGate-Modifier am Root.

AccountView zeigt im Guest-Modus eine eigene CTA-Surface („Anmelden /
Konto erstellen" + Hinweis was Login bringt). signOut nutzt
keepGuestMode: true → App bleibt nach Logout anonym nutzbar, Marketplace
und lokale Daten gehen nicht verloren.

DeckListView: Empty-State im Guest-Mode mit Login-CTA + Marketplace-
Hinweis. Toolbar-„+"-Button via authGate.require gewrappt — Tap aus
dem Guest-Modus öffnet erst das Sign-In-Sheet, danach den Editor.

DeckListStore.refresh() skippt im Guest-Mode (kein 401-Spam). Cache
wird so wie er ist gerendert (heute leer, später Marketplace-Klone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-14 01:23:30 +02:00
parent da6679770b
commit 8ca7bd3636
5 changed files with 276 additions and 160 deletions

View file

@ -1,3 +1,4 @@
import ManaAuthUI
import ManaCore
import SwiftData
import SwiftUI
@ -6,6 +7,7 @@ import SwiftUI
struct CardsNativeApp: App {
let container: ModelContainer
@State private var auth: AuthClient
@State private var authGate: ManaAuthGate
private let mediaCache: MediaCache
init() {
@ -17,6 +19,7 @@ struct CardsNativeApp: App {
let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap()
_auth = State(initialValue: auth)
_authGate = State(initialValue: ManaAuthGate(auth: auth))
mediaCache = MediaCache(api: CardsAPI(auth: auth))
Log.app.info("Cardecky starting — auth status: \(String(describing: auth.status), privacy: .public)")
}
@ -25,6 +28,7 @@ struct CardsNativeApp: App {
WindowGroup {
RootView()
.environment(auth)
.environment(authGate)
.environment(\.mediaCache, mediaCache)
.tint(CardsTheme.primary)
}

View file

@ -2,10 +2,13 @@ import ManaAuthUI
import ManaCore
import SwiftUI
/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit
/// drei Tabs (Decks / Entdecken / Account).
/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
/// Kein harter Login-Gate mehr Cardecky läuft auch im Guest-Modus
/// (lokale Decks lernen, Marketplace browsen). Schreibende Server-
/// Aktionen werden über ``ManaAuthGate`` einzeln auf Login eskaliert.
struct RootView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var selectedTab: AppTab = .decks
@State private var pendingDeepLinkSlug: String?
@State private var showCreateDeck = false
@ -17,65 +20,75 @@ struct RootView: View {
private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")!
var body: some View {
Group {
switch auth.status {
case .signedIn:
mainTabs
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
case .unknown, .signedOut, .signingIn, .error:
authSurface
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
mainTabs
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
.manaBrand(CardsBrand.manaBrand)
.manaAuthGate(authGate) {
gateSignInContent
}
.sheet(item: Binding(
get: { resetPasswordToken.map(IdentifiedString.init) },
set: { resetPasswordToken = $0?.value }
)) { token in
ManaResetPasswordView(
token: token.value,
auth: auth,
onDone: { resetPasswordToken = nil }
)
.manaBrand(CardsBrand.manaBrand)
}
.task {
// DEBUG: Auto-Login mit DebugCredentials, falls signedOut.
// Release: no-op. Danach in Guest-Mode wechseln, wenn weder
// signedIn noch eingebuchtet Cardecky soll *immer* nutzbar
// sein, auch ohne Account.
await auth.ensureSignedIn()
if case .signedOut = auth.status {
do {
_ = try auth.enterGuestMode()
} catch {
Log.auth.warning(
"Guest-Mode konnte nicht aktiviert werden: \(String(describing: error), privacy: .public)"
)
}
}
}
}
/// Content für das ``ManaAuthGate``-Sheet wenn ein gegateter Button
/// gedrückt wird, fliegt der User in den Sign-In-Flow. Sign-Up und
/// Forgot-Password werden als verschachtelte Sheets aufgeklappt,
/// damit aus dem Gate-Sheet alle Auth-Pfade erreichbar bleiben.
private var gateSignInContent: some View {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: { showSignUpSheet = true },
onForgotTapped: { showForgotSheet = true }
)
.manaBrand(CardsBrand.manaBrand)
.sheet(isPresented: $showSignUpSheet) {
ManaSignUpView(
auth: auth,
sourceAppUrl: sourceAppUrl,
onDone: { showSignUpSheet = false }
)
.manaBrand(CardsBrand.manaBrand)
}
.sheet(isPresented: $showForgotSheet) {
ManaForgotPasswordView(
auth: auth,
resetUniversalLink: resetUniversalLink,
onDone: { showForgotSheet = false }
)
.manaBrand(CardsBrand.manaBrand)
}
}
.manaBrand(CardsBrand.manaBrand)
.task {
await auth.ensureSignedIn()
}
}
@ViewBuilder
private var authSurface: some View {
ManaLoginView(
auth: auth,
onSignUpTapped: { showSignUpSheet = true },
onForgotTapped: { showForgotSheet = true }
)
.sheet(isPresented: $showSignUpSheet) {
ManaSignUpView(
auth: auth,
sourceAppUrl: sourceAppUrl,
onDone: { showSignUpSheet = false }
)
.manaBrand(CardsBrand.manaBrand)
}
.sheet(isPresented: $showForgotSheet) {
ManaForgotPasswordView(
auth: auth,
resetUniversalLink: resetUniversalLink,
onDone: { showForgotSheet = false }
)
.manaBrand(CardsBrand.manaBrand)
}
.sheet(item: Binding(
get: { resetPasswordToken.map(IdentifiedString.init) },
set: { resetPasswordToken = $0?.value }
)) { token in
ManaResetPasswordView(
token: token.value,
auth: auth,
onDone: { resetPasswordToken = nil }
)
.manaBrand(CardsBrand.manaBrand)
}
}
@ViewBuilder
private var mainTabs: some View {
TabView(selection: $selectedTab) {
DeckListView(showCreate: $showCreateDeck)
@ -93,7 +106,9 @@ struct RootView: View {
.tag(AppTab.account)
}
.decksCreateAccessory(visible: selectedTab == .decks) {
showCreateDeck = true
authGate.require(reason: "deck-create-accessory") {
showCreateDeck = true
}
}
}
@ -110,8 +125,8 @@ struct RootView: View {
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
if parts == ["auth", "reset"],
let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "token" })?.value
.queryItems?
.first(where: { $0.name == "token" })?.value
{
resetPasswordToken = token
return
@ -127,10 +142,11 @@ struct RootView: View {
/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token).
private struct IdentifiedString: Identifiable {
let value: String
var id: String { value }
var id: String {
value
}
}
enum AppTab: Hashable {
case decks
case explore
@ -141,13 +157,15 @@ private extension View {
/// iOS 26: floating Neues Deck"-Pille via `.tabViewBottomAccessory`,
/// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den
/// bestehenden `.bottomBar`-+"-Toolbar-Button in `DeckListView` zurück.
///
/// Den Modifier nur konditional anwenden sonst rendert das System
/// auch bei leerem Inhalt die leere Glass-Hülle (sichtbar als toter
/// Streifen über der TabBar auf Entdecken/Account).
@ViewBuilder
func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View {
if #available(iOS 26.0, *) {
self.tabViewBottomAccessory {
if visible {
DeckCreateAccessoryPill(action: onTap)
}
if #available(iOS 26.0, *), visible {
tabViewBottomAccessory {
DeckCreateAccessoryPill(action: onTap)
}
} else {
self
@ -163,10 +181,8 @@ private struct DeckCreateAccessoryPill: View {
Button(action: action) {
Label("Neues Deck", systemImage: "plus")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
.buttonStyle(.borderedProminent)
.buttonStyle(.glass)
.tint(CardsTheme.primary)
.accessibilityLabel("Neues Deck erstellen")
}