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
34abc2d404
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