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

@ -4,6 +4,7 @@ import SwiftUI
struct AccountView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var showChangeEmail = false
@State private var showChangePassword = false
@State private var showDeleteAccount = false
@ -11,63 +12,16 @@ struct AccountView: View {
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
VStack(spacing: 20) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundStyle(CardsTheme.primary)
if let email = auth.currentEmail {
Text(email)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
Group {
switch auth.status {
case .signedIn:
signedInContent
case .guest, .signedOut, .error, .unknown:
guestContent
case .signingIn, .twoFactorRequired:
ProgressView().tint(CardsTheme.primary)
}
VStack(spacing: 12) {
NavigationLink {
SettingsView()
} label: {
rowLabel("Einstellungen", systemImage: "gear")
}
.buttonStyle(.plain)
Button { showChangeEmail = true } label: {
rowLabel("Email ändern", systemImage: "envelope")
}
.buttonStyle(.plain)
Button { showChangePassword = true } label: {
rowLabel("Passwort ändern", systemImage: "key")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 32)
Spacer()
Button(role: .destructive) {
Task { await auth.signOut() }
} label: {
Text("Abmelden")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(CardsTheme.error)
}
.padding(.horizontal, 32)
// App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS
// eine Account-Löschung anbieten.
Button(role: .destructive) {
showDeleteAccount = true
} label: {
Text("Account löschen…")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(.bottom, 16)
}
.padding(.top, 48)
}
.navigationTitle("Account")
#if os(iOS)
@ -98,7 +52,132 @@ struct AccountView: View {
}
}
@ViewBuilder
private var signedInContent: some View {
VStack(spacing: 20) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundStyle(CardsTheme.primary)
if let email = auth.currentEmail {
Text(email)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
}
VStack(spacing: 12) {
NavigationLink {
SettingsView()
} label: {
rowLabel("Einstellungen", systemImage: "gear")
}
.buttonStyle(.plain)
Button { showChangeEmail = true } label: {
rowLabel("Email ändern", systemImage: "envelope")
}
.buttonStyle(.plain)
Button { showChangePassword = true } label: {
rowLabel("Passwort ändern", systemImage: "key")
}
.buttonStyle(.plain)
ManaTwoFactorAccountRow(auth: auth)
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.padding(.horizontal, 32)
Spacer()
Button(role: .destructive) {
// Logout behält die Guest-Identity App bleibt im
// anonymen Modus nutzbar (lokale Decks, Marketplace
// browsen). Wer alles vergessen" will, nutzt
// Account löschen".
Task { await auth.signOut(keepGuestMode: true) }
} label: {
Text("Abmelden")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(CardsTheme.error)
}
.padding(.horizontal, 32)
// App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS
// eine Account-Löschung anbieten.
Button(role: .destructive) {
showDeleteAccount = true
} label: {
Text("Account löschen…")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(.bottom, 16)
}
.padding(.top, 48)
}
private var guestContent: some View {
VStack(spacing: 20) {
Image(systemName: "person.crop.circle.dashed")
.resizable()
.frame(width: 80, height: 80)
.foregroundStyle(CardsTheme.mutedForeground)
VStack(spacing: 8) {
Text("Du nutzt Cardecky anonym")
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
Text(
"""
Marketplace und lokale Decks funktionieren ohne Konto. \
Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-\
Veröffentlichung brauchst du ein Konto.
"""
)
.font(.subheadline)
.foregroundStyle(CardsTheme.mutedForeground)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 32)
VStack(spacing: 12) {
Button {
// Trigger ohne pending-Action wir wollen einfach
// das Sign-In-Sheet öffnen. `require` mit no-op
// schaltet die Sheet-Logik des Gates ein.
authGate.require(reason: "account-tab") {}
} label: {
Text("Anmelden / Konto erstellen")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(.white)
}
.buttonStyle(.plain)
NavigationLink {
SettingsView()
} label: {
rowLabel("Einstellungen", systemImage: "gear")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 32)
Spacer()
}
.padding(.top, 48)
}
private func rowLabel(_ title: String, systemImage: String) -> some View {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)

View file

@ -1,3 +1,4 @@
import ManaAuthUI
import ManaCore
import SwiftData
import SwiftUI
@ -15,13 +16,17 @@ enum DeckRoute: Hashable {
/// `cards/apps/web/src/routes/decks/+page.svelte`.
struct DeckListView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
@Binding var showCreate: Bool
private var isGuest: Bool {
if case .signedIn = auth.status { false } else { true }
}
@State private var store: DeckListStore?
@State private var showAccount = false
@State private var pendingShares: [PendingShare] = []
@State private var path = NavigationPath()
@ -67,16 +72,6 @@ struct DeckListView: View {
.onAppear {
pendingShares = PendingShareStore.readAll()
}
.sheet(isPresented: $showAccount) {
NavigationStack {
AccountView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { showAccount = false }
}
}
}
}
}
}
@ -104,7 +99,7 @@ struct DeckListView: View {
}
private var subscribedDecks: [CachedDeck] {
decks.filter { $0.isFromMarketplace }
decks.filter(\.isFromMarketplace)
}
@ViewBuilder
@ -172,7 +167,10 @@ struct DeckListView: View {
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(14)
.background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.background(
CardsTheme.primary.opacity(0.08),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1)
@ -207,7 +205,10 @@ struct DeckListView: View {
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(14)
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.background(
CardsTheme.warning.opacity(0.12),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
.buttonStyle(.plain)
}
@ -231,13 +232,31 @@ struct DeckListView: View {
Text(message)
.foregroundStyle(CardsTheme.mutedForeground)
}
} else if isGuest {
ContentUnavailableView {
Label("Cardecky ohne Konto", systemImage: "person.crop.circle.dashed")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text(
"Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein."
)
.foregroundStyle(CardsTheme.mutedForeground)
} actions: {
Button("Anmelden / Konto erstellen") {
authGate.require(reason: "deck-list-empty") {}
}
.buttonStyle(.borderedProminent)
.tint(CardsTheme.primary)
}
} else {
ContentUnavailableView {
Label("Noch keine Decks", systemImage: "rectangle.stack")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text("Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.")
.foregroundStyle(CardsTheme.mutedForeground)
Text(
"Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab."
)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
}
@ -252,7 +271,9 @@ struct DeckListView: View {
if #unavailable(iOS 26.0) {
ToolbarItemGroup(placement: .bottomBar) {
Button {
showCreate = true
authGate.require(reason: "deck-create-toolbar") {
showCreate = true
}
} label: {
Label("Deck hinzufügen", systemImage: "plus")
.labelStyle(.iconOnly)
@ -262,19 +283,5 @@ struct DeckListView: View {
Spacer()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showAccount = true
} label: {
Image(systemName: accountIcon)
.foregroundStyle(CardsTheme.primary)
}
.accessibilityLabel("Account")
}
}
private var accountIcon: String {
if case .signedIn = auth.status { return "person.crop.circle.fill" }
return "person.crop.circle.badge.exclamationmark"
}
}