diff --git a/CHANGELOG.md b/CHANGELOG.md index 1651323..53841a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ### Hinzugefügt +- **`ManaAccountView`** (ManaAuthUI) — vereinheitlichtes Konto-Gerüst: + Form-basierter Standard-Block (E-Mail-Anzeige, E-Mail/Passwort ändern, + 2FA, Konto löschen [Apple 5.1.1(v)], Abmelden) aus einem `AuthClient`, + plus Sign-In-CTA im abgemeldeten Zustand. Config-Flags + (`showChangeEmail`/`showChangePassword`/`showTwoFactor`), + `onSignIn`/`onSignOut`-Callbacks, `@ViewBuilder extra`-Slot für + App-spezifische Sektionen. Pendant zur Login-Konsolidierung — beendet + das per-App-Handbauen des Konto-Tabs. + - **`ManaFeedbackUI`** — neues Library-Produkt. `ManaFeedbackSheet` (Kind-Picker Wunsch/Problem/Feedback, Titel, Beschreibung; bei anonymer Nutzung optionales Kontakt-Mail-Feld + Moderations-Hinweis), diff --git a/Sources/ManaAuthUI/Account/ManaAccountView.swift b/Sources/ManaAuthUI/Account/ManaAccountView.swift new file mode 100644 index 0000000..d7430ab --- /dev/null +++ b/Sources/ManaAuthUI/Account/ManaAccountView.swift @@ -0,0 +1,171 @@ +import ManaCore +import SwiftUI + +/// Vereinheitlichtes Konto-Gerüst für alle Verein-Apps. Rendert den +/// **Standard-Block** der Konto-Verwaltung — E-Mail-Anzeige, E-Mail ändern, +/// Passwort ändern, 2FA, Konto löschen (Apple 5.1.1(v)), Abmelden — aus einem +/// `AuthClient`, plus einen Sign-In-CTA im abgemeldeten Zustand. Pendant zur +/// Login-Konsolidierung: nach `ManaLoginView` ist auch der Konto-Tab überall +/// identisch, statt pro App handgebaut. +/// +/// App-spezifische Inhalte (Profil-Cards, Beitrags-Historie, Settings-Link, +/// Health-Status) kommen als `Section`s in den `extra`-Slot — sie erscheinen +/// im eingeloggten Zustand zwischen Konto-Info und Sicherheits-Block. +/// +/// Theming über `\.manaBrand` (App setzt es auf den View; vererbt sich auf die +/// Verwaltungs-Sheets). Login-Auslösung bleibt App-Sache (`onSignIn` → typisch +/// `ManaAuthGate.require`), damit die App ihren Gate-/SSO-Flow behält. +/// +/// ```swift +/// NavigationStack { +/// ManaAccountView( +/// auth: auth, +/// onSignIn: { authGate.require(reason: "konto") {} } +/// ) { +/// Section("Profil") { … app-spezifisch … } +/// } +/// .navigationTitle("Konto") +/// } +/// ``` +public struct ManaAccountView: View { + /// Welche Verwaltungs-Aktionen der Standard-Block zeigt. Apps ohne + /// Passwort-Auth (reine SSO-/Read-Apps) können E-Mail/Passwort/2FA + /// abschalten — Konto-Löschung + Abmelden bleiben immer. + public struct Config: Sendable { + public var showChangeEmail: Bool + public var showChangePassword: Bool + public var showTwoFactor: Bool + /// Universal-Link, den die E-Mail-Änderungs-Bestätigung anspringt. + public var changeEmailCallbackLink: URL? + public var signInLabel: String + public var signedOutMessage: String? + + public init( + showChangeEmail: Bool = true, + showChangePassword: Bool = true, + showTwoFactor: Bool = true, + changeEmailCallbackLink: URL? = nil, + signInLabel: String = "Mit mana-Konto anmelden", + signedOutMessage: String? = nil + ) { + self.showChangeEmail = showChangeEmail + self.showChangePassword = showChangePassword + self.showTwoFactor = showTwoFactor + self.changeEmailCallbackLink = changeEmailCallbackLink + self.signInLabel = signInLabel + self.signedOutMessage = signedOutMessage + } + } + + private let auth: AuthClient + private let config: Config + private let onSignIn: () -> Void + private let onSignOut: () async -> Void + private let extra: () -> Extra + + @State private var showChangeEmail = false + @State private var showChangePassword = false + @State private var showDeleteAccount = false + + /// - Parameters: + /// - auth: der App-`AuthClient`. + /// - config: welche Verwaltungs-Aktionen erscheinen (Default: alle). + /// - onSignIn: Login auslösen (typisch `ManaAuthGate.require`). + /// - onSignOut: Abmelden + App-Cleanup. Default: `auth.signOut()`. + /// - extra: App-spezifische `Section`s (nur eingeloggt sichtbar). + public init( + auth: AuthClient, + config: Config = .init(), + onSignIn: @escaping () -> Void, + onSignOut: (() async -> Void)? = nil, + @ViewBuilder extra: @escaping () -> Extra = { EmptyView() } + ) { + self.auth = auth + self.config = config + self.onSignIn = onSignIn + self.onSignOut = onSignOut ?? { await auth.signOut() } + self.extra = extra + } + + private var isSignedIn: Bool { + if case .signedIn = auth.status { return true } + return false + } + + private var showsSecuritySection: Bool { + config.showChangeEmail || config.showChangePassword || config.showTwoFactor + } + + public var body: some View { + Form { + if isSignedIn { + signedInSections + } else { + signedOutSection + } + } + .sheet(isPresented: $showChangeEmail) { + ManaChangeEmailView( + auth: auth, + callbackUniversalLink: config.changeEmailCallbackLink, + onDone: { showChangeEmail = false } + ) + } + .sheet(isPresented: $showChangePassword) { + ManaChangePasswordView(auth: auth, onDone: { showChangePassword = false }) + } + .sheet(isPresented: $showDeleteAccount) { + ManaDeleteAccountView(auth: auth, onDone: { showDeleteAccount = false }) + } + } + + @ViewBuilder + private var signedInSections: some View { + Section("Konto") { + if let email = auth.currentEmail { + LabeledContent("E-Mail", value: email) + } + } + + extra() + + if showsSecuritySection { + Section("Sicherheit") { + if config.showChangeEmail { + Button("E-Mail ändern") { showChangeEmail = true } + } + if config.showChangePassword { + Button("Passwort ändern") { showChangePassword = true } + } + if config.showTwoFactor { + ManaTwoFactorAccountRow(auth: auth) + } + } + } + + Section { + Button("Abmelden", role: .destructive) { + Task { await onSignOut() } + } + // Apple-Guideline 5.1.1(v): In-App-Account-Löschung Pflicht. + Button("Konto löschen…", role: .destructive) { + showDeleteAccount = true + } + } + } + + @ViewBuilder + private var signedOutSection: some View { + Section { + Button { + onSignIn() + } label: { + Label(config.signInLabel, systemImage: "person.crop.circle.badge.checkmark") + } + } footer: { + if let message = config.signedOutMessage { + Text(message) + } + } + } +}