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:
commit
0a2cb349b4
29 changed files with 2614 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
*.xcodeproj
|
||||||
|
Package.resolved
|
||||||
|
.DS_Store
|
||||||
53
CHANGELOG.md
Normal file
53
CHANGELOG.md
Normal 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
111
CLAUDE.md
Normal 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
38
Package.swift
Normal 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
34
README.md
Normal 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).
|
||||||
154
Sources/ManaAuthUI/Account/ManaChangeEmailView.swift
Normal file
154
Sources/ManaAuthUI/Account/ManaChangeEmailView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
Sources/ManaAuthUI/Account/ManaChangePasswordView.swift
Normal file
168
Sources/ManaAuthUI/Account/ManaChangePasswordView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
Sources/ManaAuthUI/Account/ManaDeleteAccountView.swift
Normal file
175
Sources/ManaAuthUI/Account/ManaDeleteAccountView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
Sources/ManaAuthUI/Brand/ManaBrandConfig.swift
Normal file
162
Sources/ManaAuthUI/Brand/ManaBrandConfig.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Sources/ManaAuthUI/Components/ManaAuthScaffold.swift
Normal file
67
Sources/ManaAuthUI/Components/ManaAuthScaffold.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Sources/ManaAuthUI/Components/ManaFields.swift
Normal file
108
Sources/ManaAuthUI/Components/ManaFields.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Sources/ManaAuthUI/Components/ManaPrimaryButton.swift
Normal file
59
Sources/ManaAuthUI/Components/ManaPrimaryButton.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
81
Sources/ManaAuthUI/Login/LoginViewModel.swift
Normal file
81
Sources/ManaAuthUI/Login/LoginViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
Sources/ManaAuthUI/Login/ManaLoginView.swift
Normal file
95
Sources/ManaAuthUI/Login/ManaLoginView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
Sources/ManaAuthUI/Register/ManaSignUpView.swift
Normal file
139
Sources/ManaAuthUI/Register/ManaSignUpView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Sources/ManaAuthUI/Register/SignUpViewModel.swift
Normal file
96
Sources/ManaAuthUI/Register/SignUpViewModel.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Sources/ManaAuthUI/Reset/ForgotPasswordViewModel.swift
Normal file
60
Sources/ManaAuthUI/Reset/ForgotPasswordViewModel.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
Sources/ManaAuthUI/Reset/ManaForgotPasswordView.swift
Normal file
111
Sources/ManaAuthUI/Reset/ManaForgotPasswordView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
Sources/ManaAuthUI/Reset/ManaResetPasswordView.swift
Normal file
127
Sources/ManaAuthUI/Reset/ManaResetPasswordView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Sources/ManaAuthUI/Reset/ResetPasswordViewModel.swift
Normal file
69
Sources/ManaAuthUI/Reset/ResetPasswordViewModel.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Sources/ManaAuthUI/Verify/EmailVerifyGateViewModel.swift
Normal file
50
Sources/ManaAuthUI/Verify/EmailVerifyGateViewModel.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Sources/ManaAuthUI/Verify/ManaEmailVerifyGateView.swift
Normal file
83
Sources/ManaAuthUI/Verify/ManaEmailVerifyGateView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
Tests/ManaAuthUITests/AccountViewModelTests.swift
Normal file
104
Tests/ManaAuthUITests/AccountViewModelTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Tests/ManaAuthUITests/EmailVerifyGateViewModelTests.swift
Normal file
44
Tests/ManaAuthUITests/EmailVerifyGateViewModelTests.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Tests/ManaAuthUITests/ForgotResetViewModelTests.swift
Normal file
97
Tests/ManaAuthUITests/ForgotResetViewModelTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Tests/ManaAuthUITests/LoginViewModelTests.swift
Normal file
86
Tests/ManaAuthUITests/LoginViewModelTests.swift
Normal 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 == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Tests/ManaAuthUITests/ManaBrandConfigTests.swift
Normal file
35
Tests/ManaAuthUITests/ManaBrandConfigTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
107
Tests/ManaAuthUITests/MockURLProtocol.swift
Normal file
107
Tests/ManaAuthUITests/MockURLProtocol.swift
Normal 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)
|
||||||
|
}
|
||||||
96
Tests/ManaAuthUITests/SignUpViewModelTests.swift
Normal file
96
Tests/ManaAuthUITests/SignUpViewModelTests.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue