v0.1.0 — initialer Sprint, vollständige Auth-Reise als SwiftUI

Phase 2 aus dem Native-Auth-Vollausbau-Plan (Option A, siehe
../mana/docs/MANA_SWIFT.md). Entstanden weil drei Apps fast-
byte-identische LoginView.swift hatten und Sign-Up/Forgot-PW
komplett fehlten.

ManaAuthUI-Library mit:
- ManaBrandConfig — App-injizierte Theme-Werte (forest für Cards/
  Manaspur, mana-default für Memoro), Environment-Key, View-Modifier
- Base-Components: ManaAuthScaffold, ManaPrimaryButton, ManaTextField,
  ManaSecureField + .manaEmailField()-Modifier
- ManaLoginView + LoginViewModel — Email/PW-Login, schaltet bei
  AuthError.emailNotVerified automatisch auf ManaEmailVerifyGateView
- ManaSignUpView + SignUpViewModel — Email/Name/PW + awaiting-
  Verification-Hinweis-Screen
- ManaEmailVerifyGateView + ViewModel — Resend-Verification
- ManaForgotPasswordView + ViewModel — Reset-Mail anfordern (immer
  generischer Hinweis, User-Enumeration-Schutz)
- ManaResetPasswordView + ViewModel — neues PW mit Token aus
  Universal-Link
- ManaChangeEmailView, ManaChangePasswordView, ManaDeleteAccountView
  + internal ViewModels — Account-Bausteine
- ManaDeleteAccountView ist zweistufig (Bestätigungs-Wort tippen
  + Passwort) → App-Store-Guideline 5.1.1(v) Pflicht-Surface

26/26 ViewModel-Tests grün via per-test-ID URLProtocol-Routing
(löst Parallel-Pollution zwischen .serialized Suites).

🤖 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 19:22:42 +02:00
commit 0a2cb349b4
29 changed files with 2614 additions and 0 deletions

View file

@ -0,0 +1,154 @@
import ManaCore
import Observation
import SwiftUI
/// Account-Sheet: Email-Adresse ändern.
///
/// Schickt eine Verifikations-Mail an die **neue** Adresse. Bis der
/// User klickt, bleibt die alte Email aktiv.
///
/// **Server-Limitation (v0.1.0):** funktioniert erst nach Phase-3-
/// Server-PR (`mana-auth` braucht Bearer-Plugin). Die UI ist fertig,
/// der Wire ist fertig, der Server muss nachziehen.
public struct ManaChangeEmailView: View {
@Environment(\.manaBrand) private var brand
@State private var model: ChangeEmailViewModel
private let onDone: () -> Void
public init(auth: AuthClient, callbackUniversalLink: URL? = nil, onDone: @escaping () -> Void) {
_model = State(initialValue: ChangeEmailViewModel(
auth: auth,
callbackUniversalLink: callbackUniversalLink
))
self.onDone = onDone
}
public var body: some View {
switch model.status {
case .done:
doneView
default:
formView
}
}
@ViewBuilder
private var formView: some View {
ManaAuthScaffold(showsHeader: false) {
VStack(spacing: 16) {
Text("Email ändern")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
Text(
"Wir schicken eine Bestätigungs-Mail an die neue Adresse. "
+ "Bis du klickst, bleibt die alte Email aktiv."
)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
ManaTextField("Neue Email", text: $model.newEmail)
.manaEmailField()
ManaPrimaryButton(
"Email ändern",
isLoading: model.isSubmitting,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
@ViewBuilder
private var doneView: some View {
ManaAuthScaffold(showsHeader: false) {
VStack(spacing: 16) {
Image(systemName: "envelope.fill")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.primary)
Text("Bestätigungs-Mail verschickt")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
Text(
"Klicke den Link in der Mail, um die Änderung zu bestätigen."
)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaPrimaryButton("Fertig") { onDone() }
.padding(.top, 16)
}
}
}
}
@MainActor
@Observable
final class ChangeEmailViewModel {
enum Status: Equatable {
case idle
case submitting
case done
case error(String)
}
var newEmail: String = ""
private(set) var status: Status = .idle
private let auth: AuthClient
private let callbackUniversalLink: URL?
init(auth: AuthClient, callbackUniversalLink: URL?) {
self.auth = auth
self.callbackUniversalLink = callbackUniversalLink
}
var canSubmit: Bool {
guard !newEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
if case .submitting = status { return false }
return true
}
var isSubmitting: Bool {
if case .submitting = status { return true }
return false
}
func submit() async {
let trimmed = newEmail.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
status = .submitting
do {
try await auth.changeEmail(newEmail: trimmed, callbackUniversalLink: callbackUniversalLink)
status = .done
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Änderung fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,168 @@
import ManaCore
import Observation
import SwiftUI
/// Account-Sheet: Passwort ändern. Erfordert aktuelles Passwort (Re-Auth).
///
/// **Server-Limitation (v0.1.0):** funktioniert erst nach Phase-3-
/// Server-PR (`mana-auth` braucht Bearer-Plugin).
public struct ManaChangePasswordView: View {
@Environment(\.manaBrand) private var brand
@State private var model: ChangePasswordViewModel
private let onDone: () -> Void
public init(auth: AuthClient, onDone: @escaping () -> Void) {
_model = State(initialValue: ChangePasswordViewModel(auth: auth))
self.onDone = onDone
}
public var body: some View {
switch model.status {
case .done:
doneView
default:
formView
}
}
@ViewBuilder
private var formView: some View {
ManaAuthScaffold(showsHeader: false) {
VStack(spacing: 16) {
Text("Passwort ändern")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
ManaSecureField(
"Aktuelles Passwort",
text: $model.currentPassword,
textContentType: .password
)
ManaSecureField(
"Neues Passwort",
text: $model.newPassword,
textContentType: .newPassword
)
ManaSecureField(
"Neues Passwort bestätigen",
text: $model.confirmPassword,
textContentType: .newPassword
)
if let hint = model.validationHint {
Text(hint)
.font(.footnote)
.foregroundStyle(brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
}
ManaPrimaryButton(
"Passwort ändern",
isLoading: model.isSubmitting,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
@ViewBuilder
private var doneView: some View {
ManaAuthScaffold(showsHeader: false) {
VStack(spacing: 16) {
Image(systemName: "lock.rotation")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.success)
Text("Passwort geändert")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
ManaPrimaryButton("Fertig") { onDone() }
.padding(.top, 16)
}
}
}
}
@MainActor
@Observable
final class ChangePasswordViewModel {
enum Status: Equatable {
case idle
case submitting
case done
case error(String)
}
var currentPassword: String = ""
var newPassword: String = ""
var confirmPassword: String = ""
private(set) var status: Status = .idle
private let auth: AuthClient
init(auth: AuthClient) {
self.auth = auth
}
var canSubmit: Bool {
guard !currentPassword.isEmpty, !newPassword.isEmpty, !confirmPassword.isEmpty else { return false }
guard newPassword == confirmPassword else { return false }
guard newPassword.count >= 8 else { return false }
if case .submitting = status { return false }
return true
}
var isSubmitting: Bool {
if case .submitting = status { return true }
return false
}
var validationHint: String? {
if !newPassword.isEmpty, newPassword.count < 8 {
return "Neues Passwort muss mindestens 8 Zeichen lang sein."
}
if !confirmPassword.isEmpty, newPassword != confirmPassword {
return "Die neuen Passwörter stimmen nicht überein."
}
return nil
}
func submit() async {
guard canSubmit else { return }
status = .submitting
do {
try await auth.changePassword(currentPassword: currentPassword, newPassword: newPassword)
currentPassword = ""
newPassword = ""
confirmPassword = ""
status = .done
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Änderung fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,175 @@
import ManaCore
import Observation
import SwiftUI
/// Account-Sheet: Account vollständig löschen.
///
/// **App-Store-Guideline 5.1.1(v):** jede App mit Account-Erstellung
/// MUSS eine Account-Löschung anbieten, die nicht über das Web läuft.
/// Dieser View deckt das Pflicht-Surface ab.
///
/// **Server-Limitation (v0.1.0):** funktioniert erst nach Phase-3-
/// Server-PR (`mana-auth` braucht Bearer-Plugin). Die UI ist fertig,
/// der Wire ist fertig.
///
/// **UX:** zweistufig User muss ein Bestätigungs-Wort tippen
/// (zusätzlich zur Passwort-Eingabe), bevor der destruktive Button
/// klickbar wird. Verhindert Fehlklicks auf einem Setting-Screen.
public struct ManaDeleteAccountView: View {
@Environment(\.manaBrand) private var brand
@State private var model: DeleteAccountViewModel
private let onDone: () -> Void
public init(auth: AuthClient, onDone: @escaping () -> Void) {
_model = State(initialValue: DeleteAccountViewModel(auth: auth))
self.onDone = onDone
}
public var body: some View {
switch model.status {
case .done:
doneView
default:
formView
}
}
@ViewBuilder
private var formView: some View {
ManaAuthScaffold(showsHeader: false) {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48, weight: .medium))
.foregroundStyle(brand.error)
.frame(maxWidth: .infinity)
Text("Account löschen")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
Text(
"Das ist endgültig. Alle deine Daten werden auf allen Servern gelöscht — "
+ "Decks, Notizen, Aufnahmen, Verläufe, alles. Kein Restore möglich."
)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
Text("Tippe **LÖSCHEN** zur Bestätigung:")
.font(.subheadline)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
ManaTextField("LÖSCHEN", text: $model.confirmationText)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.characters)
#endif
ManaSecureField(
"Passwort",
text: $model.password,
textContentType: .password
)
ManaPrimaryButton(
"Account endgültig löschen",
role: .destructive,
isLoading: model.isSubmitting,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
@ViewBuilder
private var doneView: some View {
ManaAuthScaffold(showsHeader: false) {
VStack(spacing: 16) {
Image(systemName: "trash.fill")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.mutedForeground)
Text("Account gelöscht")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
Text("Schade dass du gehst. Auf Wiedersehen.")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaPrimaryButton("Schließen") { onDone() }
.padding(.top, 16)
}
}
}
}
@MainActor
@Observable
final class DeleteAccountViewModel {
enum Status: Equatable {
case idle
case submitting
case done
case error(String)
}
var confirmationText: String = ""
var password: String = ""
private(set) var status: Status = .idle
private let auth: AuthClient
init(auth: AuthClient) {
self.auth = auth
}
var canSubmit: Bool {
guard confirmationText.uppercased() == "LÖSCHEN" else { return false }
guard !password.isEmpty else { return false }
if case .submitting = status { return false }
return true
}
var isSubmitting: Bool {
if case .submitting = status { return true }
return false
}
func submit() async {
guard canSubmit else { return }
status = .submitting
do {
try await auth.deleteAccount(password: password)
password = ""
confirmationText = ""
status = .done
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Löschen fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,162 @@
import SwiftUI
/// App-injizierte Brand- und Theme-Werte für die Auth-Reise.
///
/// `ManaAuthUI` weiß nichts über Cards, Manaspur oder Memoro die
/// konsumierende App liefert ihren App-Namen, ihren Tagline und ihren
/// Farbsatz. Cards/Manaspur fahren heute den `forest`-Theme, Memoro
/// den `mana`-Default. Beide werden hier als Werte übergeben.
///
/// **Migration zu ManaTokens-Theme-Variants:** Sobald ManaTokens
/// (mana-swift-core) mehrere Variants liefert (`mana`, `forest`, ),
/// kann eine App einfach `.forest` statt eines manuellen
/// `ManaBrandConfig` schicken Convenience-Initializer folgen dann.
/// Heute (v0.1.0) ist alles explizit, bewusst.
public struct ManaBrandConfig: Sendable {
/// Anzeige-Name der App. Wird groß auf Login/SignUp gezeigt.
public let appName: String
/// Untertitel unter dem App-Namen. Optional wenn nil, wird kein
/// Tagline gerendert.
public let tagline: String?
/// Optionales SF-Symbol, das zentral über dem App-Namen erscheint.
/// Z.B. `"rectangle.stack.fill"` für Cardecky, `"map.fill"` für
/// Manaspur. Wenn nil, wird kein Icon gerendert.
public let logoSymbol: String?
// MARK: - Theme-Farben
/// Seiten-Hintergrund.
public let background: Color
/// Standard-Text auf Background.
public let foreground: Color
/// Card, Panel, Modal, Eingabefeld.
public let surface: Color
/// Sekundär-Text, Placeholder.
public let mutedForeground: Color
/// Rahmen, Trennlinien.
public let border: Color
/// Brand-Akzent (Primary-Button, Logo-Tint).
public let primary: Color
/// Text auf Primary-Background.
public let primaryForeground: Color
/// Fehler-Text und Destruktiv-Buttons.
public let error: Color
/// Success-Text (z.B. "Email verschickt").
public let success: Color
public init(
appName: String,
tagline: String? = nil,
logoSymbol: String? = nil,
background: Color,
foreground: Color,
surface: Color,
mutedForeground: Color,
border: Color,
primary: Color,
primaryForeground: Color,
error: Color,
success: Color
) {
self.appName = appName
self.tagline = tagline
self.logoSymbol = logoSymbol
self.background = background
self.foreground = foreground
self.surface = surface
self.mutedForeground = mutedForeground
self.border = border
self.primary = primary
self.primaryForeground = primaryForeground
self.error = error
self.success = success
}
}
public extension ManaBrandConfig {
/// SwiftUI-System-Default Plattform-System-Colors, geeignet für
/// Previews und Apps ohne eigenes Brand-Theme (heute: Memoro).
/// Folgt automatisch dem Light/Dark-Mode des Systems.
static let systemDefault = ManaBrandConfig(
appName: "mana",
tagline: nil,
logoSymbol: nil,
background: PlatformPalette.background,
foreground: .primary,
surface: PlatformPalette.surface,
mutedForeground: .secondary,
border: PlatformPalette.border,
primary: .accentColor,
primaryForeground: .white,
error: .red,
success: .green
)
}
/// Plattform-Brücke für die wenigen System-Colors, die zwischen iOS
/// und macOS unterschiedliche Namen haben. Bewusst privat Apps
/// arbeiten mit `Color`-Werten, nicht mit dieser Helper-Schicht.
private enum PlatformPalette {
static var background: Color {
#if canImport(UIKit)
Color(uiColor: .systemBackground)
#elseif canImport(AppKit)
Color(nsColor: .windowBackgroundColor)
#else
Color.white
#endif
}
static var surface: Color {
#if canImport(UIKit)
Color(uiColor: .secondarySystemBackground)
#elseif canImport(AppKit)
Color(nsColor: .underPageBackgroundColor)
#else
Color.gray.opacity(0.1)
#endif
}
static var border: Color {
#if canImport(UIKit)
Color(uiColor: .separator)
#elseif canImport(AppKit)
Color(nsColor: .separatorColor)
#else
Color.gray.opacity(0.3)
#endif
}
}
/// Environment-Key für die Brand-Config. Apps setzen den am Root-View
/// einmal; alle `ManaAuthUI`-Views lesen automatisch über
/// `@Environment(\.manaBrand)`.
public struct ManaBrandConfigKey: EnvironmentKey {
public static let defaultValue: ManaBrandConfig = .systemDefault
}
public extension EnvironmentValues {
/// Brand-/Theme-Werte für `ManaAuthUI`-Views. Vom App-Root via
/// `.environment(\.manaBrand, brandConfig)` gesetzt.
var manaBrand: ManaBrandConfig {
get { self[ManaBrandConfigKey.self] }
set { self[ManaBrandConfigKey.self] = newValue }
}
}
public extension View {
/// Convenience-Modifier semantisch `.environment(\.manaBrand, ...)`.
func manaBrand(_ config: ManaBrandConfig) -> some View {
environment(\.manaBrand, config)
}
}

View file

@ -0,0 +1,67 @@
import SwiftUI
/// Gemeinsames Layout-Gerüst für alle Auth-Screens: brand-getöntes
/// Background, scrollbarer Content-Stack mit max-width, optional
/// ein zentriertes Logo + App-Name + Tagline-Block am Anfang.
///
/// Apps brauchen keine eigene NavigationStack-Wrapper das ist die
/// Aufgabe der konsumierenden View (Login/SignUp etc. sitzen ggf.
/// in einer Sheet oder einem NavigationStack der App).
public struct ManaAuthScaffold<Content: View>: View {
@Environment(\.manaBrand) private var brand
private let showsHeader: Bool
private let content: Content
/// - Parameters:
/// - showsHeader: Wenn `true`, wird oben Logo + AppName + Tagline
/// gerendert. Default `true`. Auf Account-Sub-Views (Change-
/// Password etc.) sinnvollerweise `false`.
public init(showsHeader: Bool = true, @ViewBuilder content: () -> Content) {
self.showsHeader = showsHeader
self.content = content()
}
public var body: some View {
ZStack {
brand.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
if showsHeader {
header
}
content
}
.padding(.horizontal, 24)
.padding(.top, showsHeader ? 64 : 24)
.padding(.bottom, 32)
.frame(maxWidth: 480)
.frame(maxWidth: .infinity)
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
@ViewBuilder
private var header: some View {
VStack(spacing: 12) {
if let symbol = brand.logoSymbol {
Image(systemName: symbol)
.font(.system(size: 44, weight: .medium))
.foregroundStyle(brand.primary)
}
Text(brand.appName)
.font(.system(size: 40, weight: .bold))
.foregroundStyle(brand.primary)
.multilineTextAlignment(.center)
if let tagline = brand.tagline {
Text(tagline)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
}
}
}
}

View file

@ -0,0 +1,108 @@
import SwiftUI
/// Brand-gepflegtes Eingabefeld für Email/Name/Token. SecureField-
/// Variante darunter (`ManaSecureField`).
///
/// Settings für Email-Input (Keyboard-Type, no autocap/autocorrect)
/// können via `.manaEmailField()`-Modifier convenience gesetzt werden.
public struct ManaTextField: View {
@Environment(\.manaBrand) private var brand
private let placeholder: String
@Binding private var text: String
public init(_ placeholder: String, text: Binding<String>) {
self.placeholder = placeholder
_text = text
}
public var body: some View {
TextField(placeholder, text: $text)
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(brand.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(brand.border, lineWidth: 0.5)
)
.foregroundStyle(brand.foreground)
}
}
/// Passwort-Feld. Gleiche Optik wie ``ManaTextField``, aber maskiert.
public struct ManaSecureField: View {
@Environment(\.manaBrand) private var brand
private let placeholder: String
@Binding private var text: String
private let textContentType: TextContentType
/// - Parameter textContentType: `.password` für Login-Eingabe,
/// `.newPassword` für Sign-Up oder Reset (löst iOS-Passwort-
/// Vorschlag-Sheet aus).
public init(
_ placeholder: String,
text: Binding<String>,
textContentType: TextContentType = .password
) {
self.placeholder = placeholder
_text = text
self.textContentType = textContentType
}
public var body: some View {
SecureField(placeholder, text: $text)
#if os(iOS)
.textContentType(uiTextContentType)
#elseif os(macOS)
.textContentType(nsTextContentType)
#endif
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(brand.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(brand.border, lineWidth: 0.5)
)
.foregroundStyle(brand.foreground)
}
public enum TextContentType: Sendable {
case password
case newPassword
}
#if os(iOS)
private var uiTextContentType: UITextContentType {
switch textContentType {
case .password: .password
case .newPassword: .newPassword
}
}
#elseif os(macOS)
private var nsTextContentType: NSTextContentType {
switch textContentType {
case .password: .password
case .newPassword: .newPassword
}
}
#endif
}
public extension View {
/// Convenience-Modifier für Email-Felder: kein Autocaps, kein
/// Autocorrect, Email-Keyboard auf iOS, Email-textContentType.
func manaEmailField() -> some View {
modifier(EmailFieldModifier())
}
}
private struct EmailFieldModifier: ViewModifier {
func body(content: Content) -> some View {
content
.textContentType(.emailAddress)
.autocorrectionDisabled()
#if os(iOS)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
#endif
}
}

View file

@ -0,0 +1,59 @@
import SwiftUI
/// Großer Primary-Button für Auth-Aktionen ("Anmelden", "Registrieren",
/// "Passwort setzen"). Brand-getöntes Background, dunkler Text,
/// integrierter ProgressView wenn `isLoading == true`.
public struct ManaPrimaryButton: View {
@Environment(\.manaBrand) private var brand
private let title: String
private let role: ButtonRole?
private let isLoading: Bool
private let isEnabled: Bool
private let action: () -> Void
/// - Parameters:
/// - title: Label des Buttons.
/// - role: Wenn `.destructive`, wird Brand-Error statt Brand-Primary
/// genutzt (z.B. für `ManaDeleteAccountView`). Default `nil`.
/// - isLoading: Zeigt ProgressView statt Text. Button bleibt disabled.
/// - isEnabled: Zusätzliches Disable-Flag (z.B. leere Felder).
/// - action: Callback bei Tap.
public init(
_ title: String,
role: ButtonRole? = nil,
isLoading: Bool = false,
isEnabled: Bool = true,
action: @escaping () -> Void
) {
self.title = title
self.role = role
self.isLoading = isLoading
self.isEnabled = isEnabled
self.action = action
}
public var body: some View {
Button(role: role, action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.controlSize(.small)
.tint(brand.primaryForeground)
}
Text(title)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(brand.primaryForeground)
}
.buttonStyle(.plain)
.disabled(isLoading || !isEnabled)
.opacity((isLoading || !isEnabled) ? 0.6 : 1.0)
}
private var backgroundColor: Color {
role == .destructive ? brand.error : brand.primary
}
}

View file

@ -0,0 +1,81 @@
import ManaCore
import Observation
/// State-Maschine für ``ManaLoginView``. Wraps `AuthClient.signIn` und
/// merkt sich, ob ein vorheriger Sign-In mit `.emailNotVerified`
/// gescheitert ist die UI schaltet dann auf den Resend-Mail-Gate
/// um.
@MainActor
@Observable
public final class LoginViewModel {
public enum Status: Equatable, Sendable {
case idle
case signingIn
/// Sign-In ist gescheitert mit klassifiziertem Fehler.
/// `.emailNotVerified` ist ein wichtiger Sonderfall die UI
/// schaltet darauf den Resend-Mail-Gate frei.
case error(String)
/// Sign-In ist gescheitert weil die Email noch nicht bestätigt
/// ist. UI zeigt den Resend-Gate für die zuletzt eingegebene
/// Email-Adresse.
case emailNotVerified(email: String)
}
public var email: String = ""
public var password: String = ""
public private(set) var status: Status = .idle
private let auth: AuthClient
public init(auth: AuthClient) {
self.auth = auth
}
/// Wahrheit über "kann der Submit-Button klicken?".
public var canSubmit: Bool {
guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!password.isEmpty
else { return false }
if case .signingIn = status { return false }
return true
}
public var isSigningIn: Bool {
if case .signingIn = status { return true }
return false
}
/// Zurück auf Idle wird vom ``ManaEmailVerifyGateView`` aufgerufen,
/// wenn der User "Zurück zum Login" drückt.
public func resetToIdle() {
status = .idle
password = ""
}
/// Führt den Sign-In aus. Bei Erfolg setzt `AuthClient.status` auf
/// `.signedIn` die App reagiert darauf über die Observation des
/// AuthClients selbst (z.B. Root-View switcht von Login zu Dashboard).
public func submit() async {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !password.isEmpty else { return }
status = .signingIn
await auth.signIn(email: trimmed, password: password)
switch auth.status {
case .signedIn:
status = .idle
password = "" // nicht im Memory lassen
case .error:
// Strukturierten Fehler aus AuthClient.lastError lesen statt
// den String der Status-Maschine zu re-parsen.
if case .emailNotVerified = auth.lastError {
status = .emailNotVerified(email: trimmed)
} else {
status = .error(auth.lastError?.errorDescription ?? "Login fehlgeschlagen")
}
default:
status = .idle
}
}
}

View file

@ -0,0 +1,95 @@
import ManaCore
import SwiftUI
/// Vollständiger Login-Screen: Email + Passwort + Primary-Submit
/// + Sekundär-Buttons "Konto erstellen" und "Passwort vergessen".
///
/// Bei `.emailNotVerified` schaltet die View automatisch auf
/// ``ManaEmailVerifyGateView`` um der User kann von dort die
/// Verify-Mail erneut anfordern.
///
/// Apps binden ein:
/// ```swift
/// ManaLoginView(
/// auth: authClient,
/// onSignUpTapped: { presentingSignUp = true },
/// onForgotTapped: { presentingForgot = true }
/// )
/// .manaBrand(.cardecky)
/// ```
public struct ManaLoginView: View {
@Environment(\.manaBrand) private var brand
@State private var model: LoginViewModel
private let auth: AuthClient
private let onSignUpTapped: () -> Void
private let onForgotTapped: () -> Void
/// - Parameters:
/// - auth: gemeinsamer `AuthClient` der App.
/// - onSignUpTapped: präsentiert ``ManaSignUpView`` (Sheet,
/// Push, Navigation die App entscheidet).
/// - onForgotTapped: präsentiert ``ManaForgotPasswordView``.
public init(
auth: AuthClient,
onSignUpTapped: @escaping () -> Void,
onForgotTapped: @escaping () -> Void
) {
self.auth = auth
_model = State(initialValue: LoginViewModel(auth: auth))
self.onSignUpTapped = onSignUpTapped
self.onForgotTapped = onForgotTapped
}
public var body: some View {
switch model.status {
case let .emailNotVerified(email):
ManaEmailVerifyGateView(
email: email,
auth: auth,
onBackToLogin: { model.resetToIdle() }
)
default:
loginForm
}
}
@ViewBuilder
private var loginForm: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
ManaTextField("Email", text: $model.email)
.manaEmailField()
ManaSecureField("Passwort", text: $model.password, textContentType: .password)
ManaPrimaryButton(
"Anmelden",
isLoading: model.isSigningIn,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
VStack(spacing: 12) {
Button("Konto erstellen", action: onSignUpTapped)
.font(.subheadline)
.foregroundStyle(brand.primary)
Button("Passwort vergessen?", action: onForgotTapped)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
}
.padding(.top, 8)
}
}
}

View file

@ -0,0 +1,139 @@
import ManaCore
import SwiftUI
/// Sign-Up-Screen: Email + Name (optional) + Passwort. Bei Erfolg
/// und `requireEmailVerification: true` (Default) zeigt der View
/// den Hinweis-Screen mit Resend-Mail-Button sobald die Mail
/// geklickt wurde, schließt die App den Sheet/Push manuell oder
/// reagiert auf den nächsten `signIn`-Versuch.
///
/// Apps binden ein:
/// ```swift
/// ManaSignUpView(
/// auth: authClient,
/// sourceAppUrl: URL(string: "https://cardecky.mana.how/auth/verify"),
/// onDone: { dismissSignUpSheet() }
/// )
/// ```
public struct ManaSignUpView: View {
@Environment(\.manaBrand) private var brand
@State private var model: SignUpViewModel
private let auth: AuthClient
private let onDone: () -> Void
/// - Parameters:
/// - auth: gemeinsamer `AuthClient` der App.
/// - sourceAppUrl: Universal-Link der App für den Verify-Klick-
/// Redirect (z.B. `https://cardecky.mana.how/auth/verify`).
/// - onDone: Callback wenn der User "Fertig" auf dem Hinweis-
/// Screen drückt oder direkt eingeloggt ist. Apps schließen
/// hier das Sheet.
public init(
auth: AuthClient,
sourceAppUrl: URL? = nil,
onDone: @escaping () -> Void
) {
self.auth = auth
_model = State(initialValue: SignUpViewModel(auth: auth, sourceAppUrl: sourceAppUrl))
self.onDone = onDone
}
public var body: some View {
switch model.status {
case let .awaitingVerification(email):
awaitingVerificationView(email: email)
case .signedIn:
// Server hat direkt Tokens geliefert UI muss nichts mehr
// tun, App soll den Sheet schließen.
ManaAuthScaffold(showsHeader: false) {
ProgressView()
.onAppear { onDone() }
}
default:
signUpForm
}
}
@ViewBuilder
private var signUpForm: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Text("Konto erstellen")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
ManaTextField("Email", text: $model.email)
.manaEmailField()
ManaTextField("Name (optional)", text: $model.name)
.textContentType(.name)
ManaSecureField(
"Passwort",
text: $model.password,
textContentType: .newPassword
)
if let hint = model.passwordHint {
Text(hint)
.font(.footnote)
.foregroundStyle(brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
}
ManaPrimaryButton(
"Registrieren",
isLoading: model.isRegistering,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
@ViewBuilder
private func awaitingVerificationView(email: String) -> some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.success)
Text("Fast geschafft!")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
Text(
"Wir haben eine Bestätigungs-Mail an **\(email)** geschickt. "
+ "Klicke den Link in der Mail, dann kannst du dich anmelden."
)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaPrimaryButton("Fertig") {
onDone()
}
.padding(.top, 16)
}
}
}
}

View file

@ -0,0 +1,96 @@
import Foundation
import ManaCore
import Observation
/// State-Maschine für ``ManaSignUpView``. Wraps `AuthClient.register`.
///
/// Bei `requireEmailVerification: true` (Default in mana-auth) ist die
/// erfolgreiche Registrierung kein Login der Server hat die Verify-
/// Mail verschickt, der User muss klicken. `status` wird in dem Fall
/// `.awaitingVerification(email:)` die View zeigt den Hinweis-Screen.
@MainActor
@Observable
public final class SignUpViewModel {
public enum Status: Equatable, Sendable {
case idle
case registering
/// Registrierung erfolgreich, Verify-Mail unterwegs.
case awaitingVerification(email: String)
/// Registrierung erfolgreich UND Server hat Tokens geliefert
/// (z.B. mit `requireEmailVerification: false`). User ist
/// eingeloggt.
case signedIn
case error(String)
}
public var email: String = ""
public var name: String = ""
public var password: String = ""
public private(set) var status: Status = .idle
private let auth: AuthClient
private let sourceAppUrl: URL?
public init(auth: AuthClient, sourceAppUrl: URL? = nil) {
self.auth = auth
self.sourceAppUrl = sourceAppUrl
}
public var canSubmit: Bool {
guard !trimmedEmail.isEmpty, !password.isEmpty else { return false }
if case .registering = status { return false }
return true
}
public var isRegistering: Bool {
if case .registering = status { return true }
return false
}
/// Passwort-Mindest-Check vor dem Server-Call. mana-auth
/// (Better Auth) gibt heute `WEAK_PASSWORD` zurück wenn das
/// Passwort < 8 Zeichen ist wir spiegeln das clientseitig
/// damit der User keinen Round-Trip braucht.
public var passwordHint: String? {
guard !password.isEmpty else { return nil }
if password.count < 8 {
return "Passwort muss mindestens 8 Zeichen lang sein."
}
return nil
}
private var trimmedEmail: String {
email.trimmingCharacters(in: .whitespacesAndNewlines)
}
public func submit() async {
let emailTrim = trimmedEmail
let nameTrim = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !emailTrim.isEmpty, !password.isEmpty else { return }
if let hint = passwordHint {
status = .error(hint)
return
}
status = .registering
do {
try await auth.register(
email: emailTrim,
password: password,
name: nameTrim.isEmpty ? nil : nameTrim,
sourceAppUrl: sourceAppUrl
)
password = ""
switch auth.status {
case .signedIn:
status = .signedIn
default:
status = .awaitingVerification(email: emailTrim)
}
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Registrierung fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,60 @@
import Foundation
import ManaCore
import Observation
/// State-Maschine für ``ManaForgotPasswordView``. Wraps
/// `AuthClient.forgotPassword`.
///
/// **User-Enumeration-Schutz:** Server antwortet immer 200,
/// unabhängig davon ob die Email existiert. Die UI meldet daher
/// generisch ("Wenn dein Account existiert, ist eine Mail unterwegs").
@MainActor
@Observable
public final class ForgotPasswordViewModel {
public enum Status: Equatable, Sendable {
case idle
case sending
case sent
case error(String)
}
public var email: String = ""
public private(set) var status: Status = .idle
private let auth: AuthClient
private let resetUniversalLink: URL
/// - Parameter resetUniversalLink: Universal-Link der App für den
/// Reset-Klick aus der Email. Z.B.
/// `URL(string: "https://cardecky.mana.how/auth/reset")!`.
public init(auth: AuthClient, resetUniversalLink: URL) {
self.auth = auth
self.resetUniversalLink = resetUniversalLink
}
public var canSubmit: Bool {
guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
if case .sending = status { return false }
return true
}
public var isSending: Bool {
if case .sending = status { return true }
return false
}
public func submit() async {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
status = .sending
do {
try await auth.forgotPassword(email: trimmed, resetUniversalLink: resetUniversalLink)
status = .sent
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Senden fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,111 @@
import ManaCore
import SwiftUI
/// "Passwort vergessen"-Screen: Email-Eingabe + Submit-Button.
/// Bei Erfolg zeigt der View einen generischen Hinweis (User-
/// Enumeration-Schutz).
public struct ManaForgotPasswordView: View {
@Environment(\.manaBrand) private var brand
@State private var model: ForgotPasswordViewModel
private let onDone: () -> Void
/// - Parameters:
/// - auth: gemeinsamer `AuthClient` der App.
/// - resetUniversalLink: Universal-Link für den Reset-Klick
/// aus der Email (z.B. `https://cardecky.mana.how/auth/reset`).
/// - onDone: Callback wenn der User "Fertig" auf dem Hinweis-
/// Screen drückt Apps schließen das Sheet.
public init(
auth: AuthClient,
resetUniversalLink: URL,
onDone: @escaping () -> Void
) {
_model = State(initialValue: ForgotPasswordViewModel(
auth: auth,
resetUniversalLink: resetUniversalLink
))
self.onDone = onDone
}
public var body: some View {
switch model.status {
case .sent:
sentView
default:
formView
}
}
@ViewBuilder
private var formView: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Text("Passwort vergessen?")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Gib deine Email-Adresse ein. Wir schicken dir einen Link zum Zurücksetzen.")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
ManaTextField("Email", text: $model.email)
.manaEmailField()
ManaPrimaryButton(
"Reset-Link senden",
isLoading: model.isSending,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
@ViewBuilder
private var sentView: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Image(systemName: "envelope.fill")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.primary)
Text("Schau in deinen Posteingang")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.multilineTextAlignment(.center)
Text(
"Wenn ein Account für diese Email existiert, ist eine Mail mit "
+ "einem Reset-Link unterwegs."
)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaPrimaryButton("Fertig") {
onDone()
}
.padding(.top, 16)
}
}
}
}

View file

@ -0,0 +1,127 @@
import ManaCore
import SwiftUI
/// "Neues Passwort setzen"-Screen. Wird aus dem Universal-Link-Handler
/// der App aufgerufen, sobald der User den Reset-Link aus der Email
/// geklickt hat die App extrahiert `?token=` aus der URL und
/// präsentiert diesen View.
///
/// ```swift
/// // In App.handleUniversalLink:
/// if url.path == "/auth/reset", let token = url.queryToken {
/// showResetPasswordSheet(token: token)
/// }
/// ```
public struct ManaResetPasswordView: View {
@Environment(\.manaBrand) private var brand
@State private var model: ResetPasswordViewModel
private let onDone: () -> Void
/// - Parameters:
/// - token: Reset-Token aus der Email (`?token=`).
/// - auth: gemeinsamer `AuthClient` der App.
/// - onDone: Callback bei Erfolg oder Abbruch App schließt
/// den Sheet und navigiert zurück zum Login.
public init(
token: String,
auth: AuthClient,
onDone: @escaping () -> Void
) {
_model = State(initialValue: ResetPasswordViewModel(token: token, auth: auth))
self.onDone = onDone
}
public var body: some View {
switch model.status {
case .done:
doneView
default:
formView
}
}
@ViewBuilder
private var formView: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Text("Neues Passwort")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Wähle ein neues Passwort. Mindestens 8 Zeichen.")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
ManaSecureField(
"Neues Passwort",
text: $model.newPassword,
textContentType: .newPassword
)
ManaSecureField(
"Passwort bestätigen",
text: $model.confirmPassword,
textContentType: .newPassword
)
if let hint = model.validationHint {
Text(hint)
.font(.footnote)
.foregroundStyle(brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
}
ManaPrimaryButton(
"Passwort setzen",
isLoading: model.isSubmitting,
isEnabled: model.canSubmit
) {
Task { await model.submit() }
}
if case let .error(message) = model.status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.padding(.top, 16)
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
@ViewBuilder
private var doneView: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Image(systemName: "lock.open.fill")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.success)
Text("Passwort aktualisiert")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
Text("Du kannst dich jetzt mit deinem neuen Passwort anmelden.")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaPrimaryButton("Zum Login") {
onDone()
}
.padding(.top, 16)
}
}
}
}

View file

@ -0,0 +1,69 @@
import Foundation
import ManaCore
import Observation
/// State-Maschine für ``ManaResetPasswordView``. Wraps
/// `AuthClient.resetPassword`. Wird aus dem Universal-Link-Handler
/// der App aufgerufen mit dem Token aus dem `?token=`-Query-Param.
@MainActor
@Observable
public final class ResetPasswordViewModel {
public enum Status: Equatable, Sendable {
case idle
case submitting
case done
case error(String)
}
public let token: String
public var newPassword: String = ""
public var confirmPassword: String = ""
public private(set) var status: Status = .idle
private let auth: AuthClient
public init(token: String, auth: AuthClient) {
self.token = token
self.auth = auth
}
public var canSubmit: Bool {
guard !newPassword.isEmpty, !confirmPassword.isEmpty else { return false }
guard newPassword == confirmPassword else { return false }
guard newPassword.count >= 8 else { return false }
if case .submitting = status { return false }
return true
}
public var isSubmitting: Bool {
if case .submitting = status { return true }
return false
}
/// UI-Hint je nach Eingabe-Status. Nil = alles ok oder noch leer.
public var validationHint: String? {
if !newPassword.isEmpty, newPassword.count < 8 {
return "Passwort muss mindestens 8 Zeichen lang sein."
}
if !confirmPassword.isEmpty, newPassword != confirmPassword {
return "Die Passwörter stimmen nicht überein."
}
return nil
}
public func submit() async {
guard canSubmit else { return }
status = .submitting
do {
try await auth.resetPassword(token: token, newPassword: newPassword)
newPassword = ""
confirmPassword = ""
status = .done
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Reset fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,50 @@
import Foundation
import ManaCore
import Observation
/// State-Maschine für ``ManaEmailVerifyGateView``. Wraps
/// `AuthClient.resendVerification`.
@MainActor
@Observable
public final class EmailVerifyGateViewModel {
public enum Status: Equatable, Sendable {
case idle
case resending
case resent(String)
case error(String)
}
public let email: String
public private(set) var status: Status = .idle
private let auth: AuthClient
private let sourceAppUrl: URL?
public init(email: String, auth: AuthClient, sourceAppUrl: URL? = nil) {
self.email = email
self.auth = auth
self.sourceAppUrl = sourceAppUrl
}
public var canResend: Bool {
if case .resending = status { return false }
return true
}
public var isResending: Bool {
if case .resending = status { return true }
return false
}
public func resend() async {
status = .resending
do {
try await auth.resendVerification(email: email, sourceAppUrl: sourceAppUrl)
status = .resent("Bestätigungs-Mail wurde verschickt. Schau in deinen Posteingang.")
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Senden fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
}

View file

@ -0,0 +1,83 @@
import ManaCore
import SwiftUI
/// Wird angezeigt, wenn ein Login-Versuch mit
/// ``AuthError/emailNotVerified`` gescheitert ist. Bietet einen
/// Resend-Mail-Button und einen "Zurück zum Login"-Pfad.
///
/// Die Apps bauen das nicht direkt ein ``ManaLoginView`` schaltet
/// automatisch um wenn der Sign-In den entsprechenden Fehler liefert.
public struct ManaEmailVerifyGateView: View {
@Environment(\.manaBrand) private var brand
@State private var model: EmailVerifyGateViewModel
private let onBackToLogin: () -> Void
public init(
email: String,
auth: AuthClient,
sourceAppUrl: URL? = nil,
onBackToLogin: @escaping () -> Void
) {
_model = State(initialValue: EmailVerifyGateViewModel(
email: email,
auth: auth,
sourceAppUrl: sourceAppUrl
))
self.onBackToLogin = onBackToLogin
}
public var body: some View {
ManaAuthScaffold {
VStack(spacing: 16) {
Image(systemName: "envelope.badge")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.primary)
.padding(.bottom, 8)
Text("Bestätige deine Email")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
.multilineTextAlignment(.center)
Text(
"Wir haben dir eine Bestätigungs-Mail an **\(model.email)** geschickt. "
+ "Klicke den Link in der Mail, dann kannst du dich anmelden."
)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaPrimaryButton(
"Bestätigungs-Mail erneut senden",
isLoading: model.isResending,
isEnabled: model.canResend
) {
Task { await model.resend() }
}
.padding(.top, 8)
switch model.status {
case let .resent(message):
Text(message)
.font(.footnote)
.foregroundStyle(brand.success)
.multilineTextAlignment(.center)
case let .error(message):
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
default:
EmptyView()
}
Button("Zurück zum Login", action: onBackToLogin)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 16)
}
}
}
}