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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.build/
.swiftpm/
*.xcodeproj
Package.resolved
.DS_Store

53
CHANGELOG.md Normal file
View file

@ -0,0 +1,53 @@
# Changelog
Alle Änderungen werden hier dokumentiert. Format orientiert an
[Keep a Changelog](https://keepachangelog.com), Versionierung nach
[Semver](https://semver.org).
## [Unreleased]
## [0.1.0] — 2026-05-13
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`-Dateien hatten und Sign-Up/Forgot-PW
komplett fehlten.
### ManaAuthUI (neu)
- `ManaBrandConfig` — App-injiziertes Bündel aus appName, tagline,
primary/surface/background/error-Colors. Apps liefern hier ihr
Theme (z.B. forest für Cards/Manaspur, default-mana für Memoro).
- Base-Components: `ManaAuthScaffold`, `ManaPrimaryButton`,
`ManaTextField`, `ManaSecureField` — geteilte Bausteine, alle
brand-aware.
- `ManaLoginView` + `LoginViewModel` — Email/PW-Login mit
Sign-Up- und Forgot-PW-Buttons. Bei `.emailNotVerified` automatisch
ins `ManaEmailVerifyGateView` umgeleitet (Resend-Mail-Button).
- `ManaSignUpView` + `SignUpViewModel` — Registrierung mit
Email/Name/Passwort. Nach Submit: Bestätigungs-Mail-Hinweis-Screen.
- `ManaEmailVerifyGateView` — wenn Login `.emailNotVerified` warf,
bietet "Bestätigungs-Mail erneut senden".
- `ManaForgotPasswordView` + `ForgotPasswordViewModel` — Reset-Mail
anfordern. Server antwortet immer 200 (keine User-Enumeration),
UI meldet generisch.
- `ManaResetPasswordView` + `ResetPasswordViewModel` — neues
Passwort setzen mit Token aus Reset-Mail. Wird aus dem
Universal-Link-Handler der App aufgerufen.
- `ManaChangeEmailView`, `ManaChangePasswordView`,
`ManaDeleteAccountView` — Account-Bausteine für die AccountView
der App. **`ManaDeleteAccountView` ist App-Store-Pflicht
(Guideline 5.1.1(v))** für jede App mit Account-Erstellung.
### Tests
- ViewModel-Tests via URLProtocol-Mock für jeden Auth-Flow.
- Brand-Config-Defaults.
### Bekannte Einschränkungen
- `ManaChangeEmailView`/`ManaChangePasswordView`/`ManaDeleteAccountView`
funktionieren erst nach Phase-3-Server-PR (Bearer-Plugin in
`mana-auth`). UI ist fertig, Wire ist fertig, Server muss nachziehen.
- 2FA, Magic-Link, Passkey-Flows nicht enthalten. Folgen in v0.2.0
zusammen mit ManaCore v1.2.0 und dem Server-PR.

111
CLAUDE.md Normal file
View file

@ -0,0 +1,111 @@
# CLAUDE.md — mana-swift-ui
Guidance für Claude Code in diesem Repo.
> **Übergeordneter Plan:** `../mana/docs/MANA_SWIFT.md` ist die SOT für
> die ganze native-App-Plattform. Phase ε dort beschreibt dieses Paket.
## Was dieses Repo ist
Swift-Package mit native UI-Komponenten für alle nativen
mana-e.V.-Apps. Heute genau ein Library-Product:
- **ManaAuthUI** — vollständige Auth-Reise als SwiftUI-Views:
Login, Sign-Up, Email-Verifikation, Passwort-Reset,
Account-Management. Konsumiert `ManaCore` (für API-Calls) und
`ManaTokens` (für Vereins-Designwerte).
Wird konsumiert von `cards-native`, `manaspur-native`, `memoro-native`,
geplant `nutriphi-native` und weiteren.
## Status
**v0.1.0 — initiale Extraktion 2026-05-13.**
Phase 2 aus dem Native-Auth-Vollausbau-Plan (Option A — alles nativ).
Entstanden weil drei Apps fast-byte-identische `LoginView.swift`-
Dateien hatten und Sign-Up/Forgot-PW komplett fehlten.
## Architektonische Invarianten
Nicht ohne explizite Diskussion antasten:
1. **Keine externen UI-Libraries.** Pure SwiftUI. Identische Regel wie
in `ManaCore` (`mana-swift-core/CLAUDE.md` Invariante 2).
2. **App injiziert `ManaBrandConfig`.** Keine hardcoded Farben oder
App-Namen im Paket. `forest`/`mana`/zukünftige Themes leben in der
App, nicht hier — bis ManaTokens-Theme-Variants kommen.
3. **ViewModels sind testbar, Views sind dünn.** Jede non-triviale
View hat einen begleitenden `@Observable`-ViewModel mit reiner
State-Maschine. URLProtocol-Mock-Tests gehen gegen ViewModels.
4. **Lokalisierung DE first.** Public-API-Strings auf Deutsch, EN
später via `Localizable.xcstrings`. Konsistent mit Verein-Konvention.
5. **Account-Löschung ist Pflicht-Komponente.** App-Store-Guideline
5.1.1(v) — `ManaDeleteAccountView` MUSS in jeder App, die
`ManaSignUpView` einbaut, erreichbar sein.
6. **iOS 18 / macOS 15.** Gleiche Minimums wie `mana-swift-core`.
7. **Public API ist `Sendable`.** Swift-6-Strict-Concurrency.
## Repo-Layout
```
mana-swift-ui/
├── Package.swift
├── README.md
├── CHANGELOG.md
├── CLAUDE.md dieses File
├── Sources/
│ └── ManaAuthUI/
│ ├── Brand/ ManaBrandConfig (App-injiziert)
│ ├── Components/ ManaAuthScaffold, ManaTextField, ...
│ ├── Login/ ManaLoginView + ViewModel
│ ├── Register/ ManaSignUpView + ViewModel
│ ├── Verify/ ManaEmailVerifyGateView
│ ├── Reset/ ManaForgotPasswordView, ManaResetPasswordView
│ └── Account/ ChangeEmail/ChangePassword/DeleteAccount
└── Tests/
└── ManaAuthUITests/
```
## Konventionen
- **Swift 6.0**, Strict Concurrency komplett
- **iOS 18 / macOS 15** Minimum
- **Tabs** für Indent? Nein — wir nutzen 4 Spaces wie `mana-swift-core`
(SwiftFormat `.swiftformat`). Verein-TS-Repos nutzen Tabs, Swift-Repos
Spaces — bewusster Bruch wegen Xcode-Tooling
- **Doc-Comments** pflicht auf jedem `public`-Symbol (`///`)
- **Lokalisierung:** Public-API-Strings auf Deutsch
## Versionierung
- **Semver strikt.** UI ändert sich öfter als Auth-Core — getrenntes
Repo damit `mana-swift-core` stabil bleibt
- **CHANGELOG.md pflicht.** Was hat sich geändert, müssen Apps was anpassen
- **Pflege-Politik:** Letzte zwei Minor-Versionen mit Patches
## Lokal entwickeln
```bash
swift build # baut ManaAuthUI
swift test # Unit-Tests gegen ViewModels
```
`mana-swift-core` muss als Schwester-Verzeichnis existieren.
## Wenn ein neuer Auth-Flow dazukommt
1. ViewModel zuerst — pure State-Maschine, testbar
2. SwiftUI-View dünn, nur `@Observable`-State-Bindings
3. Public-API-Doc auf Deutsch
4. ViewModel-Tests via URLProtocol-Mock (Pattern in
`ManaAuthUITests/`)
5. CHANGELOG.md ergänzen
6. Bei Breaking-Change: Major-Bump-Plan + alle aktiven Apps informieren
## Wichtige Cross-Repo-Doks
- `../mana/docs/MANA_SWIFT.md` — Plattform-SOT, Phase ε
- `../mana-swift-core/CLAUDE.md` — Auth-Core, ManaTokens
- `../mana/docs/THEMING.md` — Token-SOT, spiegelt sich in ManaTokens
- `../mana/services/mana-auth/CLAUDE.md` — Server-Konventionen

38
Package.swift Normal file
View file

@ -0,0 +1,38 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "mana-swift-ui",
defaultLocalization: "de",
platforms: [
.iOS(.v18),
.macOS(.v15),
],
products: [
.library(name: "ManaAuthUI", targets: ["ManaAuthUI"]),
],
dependencies: [
// Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über
// `path: ../mana-swift-core` bzw. `path: ../mana-swift-ui`.
// Release-Wechsel auf `from: "1.1.0"` kommt mit Phase 4.
.package(path: "../mana-swift-core"),
],
targets: [
.target(
name: "ManaAuthUI",
dependencies: [
.product(name: "ManaCore", package: "mana-swift-core"),
.product(name: "ManaTokens", package: "mana-swift-core"),
],
path: "Sources/ManaAuthUI",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency"),
]
),
.testTarget(
name: "ManaAuthUITests",
dependencies: ["ManaAuthUI"],
path: "Tests/ManaAuthUITests"
),
]
)

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# mana-swift-ui
Native SwiftUI-Komponenten für alle nativen Apps des Vereins
**mana e.V.** Heute genau ein Library-Product:
- **ManaAuthUI** — vollständige Auth-Reise (Login, Sign-Up,
Email-Verifikation, Passwort-Reset, Account-Management) als
SwiftUI-Views. Konsumiert `ManaCore` für API-Calls und
`ManaTokens` für Vereins-Designwerte.
Wird konsumiert von `cards-native`, `manaspur-native`,
`memoro-native` und allen kommenden Verein-Apps.
## Verhältnis zu `mana-swift-core`
`mana-swift-core` liefert die Wire-/Auth-/Token-Schicht:
`AuthClient`, `AuthenticatedTransport`, `KeychainStore`, Farben,
Spacings. Dieses Paket setzt darauf auf und liefert UI.
Getrennte Repos, damit `mana-swift-core` stabil bleibt während sich
die UI-Komponenten organisch entwickeln.
## Lokal entwickeln
```bash
swift build
swift test
```
`../mana-swift-core/` muss als Schwester-Verzeichnis existieren.
## Konventionen
Siehe [`CLAUDE.md`](CLAUDE.md).

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)
}
}
}
}

View file

@ -0,0 +1,104 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("Account-ViewModels (ChangeEmail/ChangePW/Delete)")
@MainActor
struct AccountViewModelTests {
/// Simuliert eingeloggten User über den echten `signIn`-Pfad
/// mit Mock-URL, weil `persistSession` internal in ManaCore ist.
private func signedInAuth() async -> MockedAuth {
let mocked = makeMockedAuth()
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
return mocked
}
// MARK: - ChangeEmail
@Test("changeEmail erfolgreich → .done")
func changeEmailSuccess() async {
let mocked = await signedInAuth()
let model = ChangeEmailViewModel(auth: mocked.auth, callbackUniversalLink: nil)
model.newEmail = "neu@x.de"
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.submit()
#expect(model.status == .done)
}
@Test("changeEmail ohne Session → .error mit notSignedIn")
func changeEmailNoSession() async {
let model = ChangeEmailViewModel(auth: makeMockedAuth().auth, callbackUniversalLink: nil)
model.newEmail = "neu@x.de"
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Nicht angemeldet")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
// MARK: - ChangePassword
@Test("changePassword erfolgreich → .done und cleared Felder")
func changePasswordSuccess() async {
let mocked = await signedInAuth()
let model = ChangePasswordViewModel(auth: mocked.auth)
model.currentPassword = "alt-lang-genug"
model.newPassword = "neu-lang-genug"
model.confirmPassword = "neu-lang-genug"
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.submit()
#expect(model.status == .done)
#expect(model.currentPassword == "")
#expect(model.newPassword == "")
#expect(model.confirmPassword == "")
}
@Test("changePassword Mismatch → canSubmit false")
func changePasswordMismatch() {
let model = ChangePasswordViewModel(auth: makeMockedAuth().auth)
model.currentPassword = "alt"
model.newPassword = "neu-lang-genug"
model.confirmPassword = "anders-lang-genug"
#expect(model.canSubmit == false)
}
// MARK: - DeleteAccount
@Test("deleteAccount mit korrektem Bestätigungswort → .done")
func deleteAccountSuccess() async {
let mocked = await signedInAuth()
let model = DeleteAccountViewModel(auth: mocked.auth)
model.confirmationText = "LÖSCHEN"
model.password = "pw"
mocked.setHandler { request in
#expect(request.httpMethod == "DELETE")
return (200, Data(#"{"success":true}"#.utf8))
}
await model.submit()
#expect(model.status == .done)
#expect(mocked.auth.status == .signedOut)
}
@Test("deleteAccount mit falschem Bestätigungswort → canSubmit false")
func deleteAccountWrongConfirmation() {
let model = DeleteAccountViewModel(auth: makeMockedAuth().auth)
model.confirmationText = "delete"
model.password = "pw"
#expect(model.canSubmit == false)
model.confirmationText = "LÖSCHEN"
#expect(model.canSubmit == true)
// Case-insensitive
model.confirmationText = "löschen"
#expect(model.canSubmit == true)
}
}

View file

@ -0,0 +1,44 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("EmailVerifyGateViewModel")
@MainActor
struct EmailVerifyGateViewModelTests {
@Test("resend erfolgreich → .resent")
func resendSuccess() async {
let mocked = makeMockedAuth()
let model = EmailVerifyGateViewModel(email: "u@x.de", auth: mocked.auth)
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.resend()
if case .resent = model.status {
#expect(Bool(true))
} else {
Issue.record("Expected .resent, got \(model.status)")
}
}
@Test("Rate-Limit → .error mit Retry-Hint")
func resendRateLimited() async {
let mocked = makeMockedAuth()
let model = EmailVerifyGateViewModel(email: "u@x.de", auth: mocked.auth)
mocked.setHandler { _ in
(
429,
Data(#"{"error":"RATE_LIMITED","retryAfterSec":42,"status":429}"#.utf8),
["Retry-After": "42"]
)
}
await model.resend()
if case let .error(message) = model.status {
#expect(message == "Zu viele Versuche. Bitte warte 42s.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
}

View file

@ -0,0 +1,97 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("ForgotPasswordViewModel + ResetPasswordViewModel")
@MainActor
struct ForgotResetViewModelTests {
// MARK: - ForgotPassword
@Test("forgotPassword erfolgreich → .sent")
func forgotSuccess() async {
let mocked = makeMockedAuth()
let model = ForgotPasswordViewModel(
auth: mocked.auth,
resetUniversalLink: URL(string: "https://cardecky.mana.how/auth/reset")!
)
model.email = "u@x.de"
let captured = MockURLProtocol.Capture()
mocked.setHandler { request in
captured.store(request)
return (200, Data(#"{"success":true}"#.utf8))
}
await model.submit()
#expect(model.status == .sent)
#expect(captured.request?.url?.path == "/api/v1/auth/forgot-password")
}
@Test("forgotPassword leere Email → canSubmit false")
func forgotGuards() {
let model = ForgotPasswordViewModel(
auth: makeMockedAuth().auth,
resetUniversalLink: URL(string: "https://x.test/auth/reset")!
)
#expect(model.canSubmit == false)
model.email = "u@x.de"
#expect(model.canSubmit == true)
}
// MARK: - ResetPassword
@Test("resetPassword erfolgreich → .done")
func resetSuccess() async {
let mocked = makeMockedAuth()
let model = ResetPasswordViewModel(token: "tok123", auth: mocked.auth)
model.newPassword = "Neu-987654321"
model.confirmPassword = "Neu-987654321"
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.submit()
#expect(model.status == .done)
#expect(model.newPassword == "")
#expect(model.confirmPassword == "")
}
@Test("resetPassword mit abgelaufenem Token → .error")
func resetTokenExpired() async {
let mocked = makeMockedAuth()
let model = ResetPasswordViewModel(token: "old", auth: mocked.auth)
model.newPassword = "Neu-987654321"
model.confirmPassword = "Neu-987654321"
mocked.setHandler { _ in
(400, Data(#"{"error":"TOKEN_EXPIRED","status":400}"#.utf8))
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Der Link ist abgelaufen. Bitte fordere einen neuen an.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("resetPassword validationHint bei Mismatch")
func resetMismatch() {
let model = ResetPasswordViewModel(token: "t", auth: makeMockedAuth().auth)
model.newPassword = "lang-genug"
model.confirmPassword = "anders-lang"
#expect(model.validationHint == "Die Passwörter stimmen nicht überein.")
#expect(model.canSubmit == false)
}
@Test("resetPassword canSubmit erfordert ≥8 Zeichen und Match")
func resetCanSubmit() {
let model = ResetPasswordViewModel(token: "t", auth: makeMockedAuth().auth)
model.newPassword = "kurz"
model.confirmPassword = "kurz"
#expect(model.canSubmit == false)
model.newPassword = "lang-genug"
model.confirmPassword = "lang-genug"
#expect(model.canSubmit == true)
}
}

View file

@ -0,0 +1,86 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("LoginViewModel")
@MainActor
struct LoginViewModelTests {
@Test("Erfolgreicher Login setzt status auf .idle und cleared das Passwort")
func successfulLogin() async throws {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "Aa-123456789"
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8))
}
await model.submit()
#expect(model.status == .idle)
#expect(model.password == "")
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
}
@Test("EMAIL_NOT_VERIFIED schaltet ViewModel auf .emailNotVerified")
func emailNotVerifiedPath() async {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "pw"
mocked.setHandler { _ in
(403, Data(#"{"error":"EMAIL_NOT_VERIFIED","status":403}"#.utf8))
}
await model.submit()
if case let .emailNotVerified(email) = model.status {
#expect(email == "u@x.de")
} else {
Issue.record("Expected .emailNotVerified, got \(model.status)")
}
}
@Test("Invalid-Credentials liefert .error mit deutscher Nachricht")
func invalidCredentials() async {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "wrong"
mocked.setHandler { _ in
(401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8))
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Email oder Passwort falsch")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("canSubmit ist false bei leeren Feldern")
func canSubmitGuards() {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
#expect(model.canSubmit == false)
model.email = "u@x.de"
#expect(model.canSubmit == false)
model.password = "pw"
#expect(model.canSubmit == true)
}
@Test("resetToIdle bringt Status zurück auf idle")
func resetToIdleClearsState() {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "pw"
model.resetToIdle()
#expect(model.status == .idle)
#expect(model.password == "")
}
}

View file

@ -0,0 +1,35 @@
import SwiftUI
import Testing
@testable import ManaAuthUI
@Suite("ManaBrandConfig")
struct ManaBrandConfigTests {
@Test("systemDefault setzt sinnvolle Defaults")
func systemDefaultDefaults() {
let config = ManaBrandConfig.systemDefault
#expect(config.appName == "mana")
#expect(config.tagline == nil)
#expect(config.logoSymbol == nil)
}
@Test("Apps können ein eigenes Brand-Config bauen")
func customConfig() {
let cardecky = ManaBrandConfig(
appName: "Cardecky",
tagline: "Karteikarten des Vereins mana e.V.",
logoSymbol: "rectangle.stack.fill",
background: .white,
foreground: .black,
surface: .gray,
mutedForeground: .gray,
border: .gray,
primary: .green,
primaryForeground: .white,
error: .red,
success: .green
)
#expect(cardecky.appName == "Cardecky")
#expect(cardecky.tagline == "Karteikarten des Vereins mana e.V.")
#expect(cardecky.logoSymbol == "rectangle.stack.fill")
}
}

View file

@ -0,0 +1,107 @@
import Foundation
import ManaCore
/// URLProtocol-Mock mit Pro-Test-Routing: jede Test-AuthClient-
/// Instanz kriegt eine eigene Test-ID als HTTP-Header, der Mock
/// routet nach ID zum richtigen Handler. Das löst die Parallel-
/// Pollution zwischen Test-Suites (mehrere `.serialized`-Suites
/// laufen untereinander parallel, der globale Handler-Slot wäre
/// sonst ein Race).
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
typealias Handler = @Sendable (URLRequest) -> Any
nonisolated(unsafe) static var handlersStorage: [String: Handler] = [:]
static let handlersLock = NSLock()
static func register(testID: String, handler: @escaping Handler) {
handlersLock.lock(); defer { handlersLock.unlock() }
handlersStorage[testID] = handler
}
static func unregister(testID: String) {
handlersLock.lock(); defer { handlersLock.unlock() }
handlersStorage.removeValue(forKey: testID)
}
private static func lookup(testID: String) -> Handler? {
handlersLock.lock(); defer { handlersLock.unlock() }
return handlersStorage[testID]
}
final class Capture: @unchecked Sendable {
private let lock = NSLock()
private var stored: URLRequest?
func store(_ r: URLRequest) {
lock.lock(); defer { lock.unlock() }
stored = r
}
var request: URLRequest? {
lock.lock(); defer { lock.unlock() }
return stored
}
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func stopLoading() {}
override func startLoading() {
let testID = request.value(forHTTPHeaderField: "X-Test-ID") ?? ""
guard let handler = MockURLProtocol.lookup(testID: testID) else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
let result = handler(request)
let status: Int
let body: Data
let headers: [String: String]
if let tuple = result as? (Int, Data, [String: String]) {
status = tuple.0; body = tuple.1; headers = tuple.2
} else if let tuple = result as? (Int, Data) {
status = tuple.0; body = tuple.1; headers = [:]
} else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
}
}
/// Bündelt einen `AuthClient` mit seiner Test-ID. Tests setzen den
/// Handler über die Test-ID statt über einen globalen Slot.
@MainActor
struct MockedAuth {
let auth: AuthClient
let testID: String
func setHandler(_ handler: @escaping MockURLProtocol.Handler) {
MockURLProtocol.register(testID: testID, handler: handler)
}
}
@MainActor
func makeMockedAuth() -> MockedAuth {
let testID = UUID().uuidString
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
configuration.httpAdditionalHeaders = ["X-Test-ID": testID]
let session = URLSession(configuration: configuration)
let config = DefaultManaAppConfig(
authBaseURL: URL(string: "https://auth.test")!,
keychainService: "ev.mana.test.\(testID)",
keychainAccessGroup: nil
)
return MockedAuth(auth: AuthClient(config: config, session: session), testID: testID)
}

View file

@ -0,0 +1,96 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("SignUpViewModel")
@MainActor
struct SignUpViewModelTests {
@Test("Registrierung ohne Tokens → awaitingVerification")
func awaitsVerification() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "new@x.de"
model.password = "Aa-123456789"
mocked.setHandler { _ in
(200, Data(#"{"user":{"id":"u1","email":"new@x.de"}}"#.utf8))
}
await model.submit()
if case let .awaitingVerification(email) = model.status {
#expect(email == "new@x.de")
} else {
Issue.record("Expected .awaitingVerification, got \(model.status)")
}
#expect(model.password == "")
}
@Test("Registrierung mit Tokens → signedIn")
func registersAndSignsIn() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "new@x.de"
model.password = "Aa-123456789"
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"""
{"user":{"id":"u1","email":"new@x.de"},"accessToken":"\#(access)","refreshToken":"r"}
"""#.utf8))
}
await model.submit()
#expect(model.status == .signedIn)
}
@Test("Email-Konflikt liefert lokalisierte Fehlermeldung")
func emailConflict() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "old@x.de"
model.password = "Aa-123456789"
mocked.setHandler { _ in
(409, Data(#"{"error":"EMAIL_ALREADY_REGISTERED","status":409}"#.utf8))
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Diese Email ist bereits registriert.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("passwordHint warnt bei zu kurzem Passwort")
func passwordHint() {
let model = SignUpViewModel(auth: makeMockedAuth().auth)
model.password = ""
#expect(model.passwordHint == nil)
model.password = "kurz"
#expect(model.passwordHint == "Passwort muss mindestens 8 Zeichen lang sein.")
model.password = "lang-genug"
#expect(model.passwordHint == nil)
}
@Test("submit mit zu kurzem Passwort macht keinen Server-Call")
func submitGuardsShortPassword() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "kurz"
mocked.setHandler { _ in
Issue.record("Server darf nicht aufgerufen werden")
return (500, Data())
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Passwort muss mindestens 8 Zeichen lang sein.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
}