commit 0a2cb349b4e57a5cc57817675c3d2245eefb78ea Author: Till JS Date: Wed May 13 19:22:42 2026 +0200 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89631fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.build/ +.swiftpm/ +*.xcodeproj +Package.resolved +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f95fbc5 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e2e54ce --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..482de59 --- /dev/null +++ b/Package.swift @@ -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" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..41c9aa2 --- /dev/null +++ b/README.md @@ -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). diff --git a/Sources/ManaAuthUI/Account/ManaChangeEmailView.swift b/Sources/ManaAuthUI/Account/ManaChangeEmailView.swift new file mode 100644 index 0000000..6e7e7b6 --- /dev/null +++ b/Sources/ManaAuthUI/Account/ManaChangeEmailView.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Account/ManaChangePasswordView.swift b/Sources/ManaAuthUI/Account/ManaChangePasswordView.swift new file mode 100644 index 0000000..47a5d2c --- /dev/null +++ b/Sources/ManaAuthUI/Account/ManaChangePasswordView.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Account/ManaDeleteAccountView.swift b/Sources/ManaAuthUI/Account/ManaDeleteAccountView.swift new file mode 100644 index 0000000..477c4a8 --- /dev/null +++ b/Sources/ManaAuthUI/Account/ManaDeleteAccountView.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift b/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift new file mode 100644 index 0000000..d313285 --- /dev/null +++ b/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift @@ -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) + } +} diff --git a/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift b/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift new file mode 100644 index 0000000..e31610f --- /dev/null +++ b/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift @@ -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: 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) + } + } + } +} diff --git a/Sources/ManaAuthUI/Components/ManaFields.swift b/Sources/ManaAuthUI/Components/ManaFields.swift new file mode 100644 index 0000000..3996c63 --- /dev/null +++ b/Sources/ManaAuthUI/Components/ManaFields.swift @@ -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) { + 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, + 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 + } +} diff --git a/Sources/ManaAuthUI/Components/ManaPrimaryButton.swift b/Sources/ManaAuthUI/Components/ManaPrimaryButton.swift new file mode 100644 index 0000000..cd9579d --- /dev/null +++ b/Sources/ManaAuthUI/Components/ManaPrimaryButton.swift @@ -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 + } +} diff --git a/Sources/ManaAuthUI/Login/LoginViewModel.swift b/Sources/ManaAuthUI/Login/LoginViewModel.swift new file mode 100644 index 0000000..8de3b9c --- /dev/null +++ b/Sources/ManaAuthUI/Login/LoginViewModel.swift @@ -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 + } + } +} diff --git a/Sources/ManaAuthUI/Login/ManaLoginView.swift b/Sources/ManaAuthUI/Login/ManaLoginView.swift new file mode 100644 index 0000000..5632834 --- /dev/null +++ b/Sources/ManaAuthUI/Login/ManaLoginView.swift @@ -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) + } + } +} diff --git a/Sources/ManaAuthUI/Register/ManaSignUpView.swift b/Sources/ManaAuthUI/Register/ManaSignUpView.swift new file mode 100644 index 0000000..21ce631 --- /dev/null +++ b/Sources/ManaAuthUI/Register/ManaSignUpView.swift @@ -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) + } + } + } +} diff --git a/Sources/ManaAuthUI/Register/SignUpViewModel.swift b/Sources/ManaAuthUI/Register/SignUpViewModel.swift new file mode 100644 index 0000000..b856785 --- /dev/null +++ b/Sources/ManaAuthUI/Register/SignUpViewModel.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Reset/ForgotPasswordViewModel.swift b/Sources/ManaAuthUI/Reset/ForgotPasswordViewModel.swift new file mode 100644 index 0000000..81a3e91 --- /dev/null +++ b/Sources/ManaAuthUI/Reset/ForgotPasswordViewModel.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Reset/ManaForgotPasswordView.swift b/Sources/ManaAuthUI/Reset/ManaForgotPasswordView.swift new file mode 100644 index 0000000..83d0d87 --- /dev/null +++ b/Sources/ManaAuthUI/Reset/ManaForgotPasswordView.swift @@ -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) + } + } + } +} diff --git a/Sources/ManaAuthUI/Reset/ManaResetPasswordView.swift b/Sources/ManaAuthUI/Reset/ManaResetPasswordView.swift new file mode 100644 index 0000000..3caeb49 --- /dev/null +++ b/Sources/ManaAuthUI/Reset/ManaResetPasswordView.swift @@ -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) + } + } + } +} diff --git a/Sources/ManaAuthUI/Reset/ResetPasswordViewModel.swift b/Sources/ManaAuthUI/Reset/ResetPasswordViewModel.swift new file mode 100644 index 0000000..9cbb81d --- /dev/null +++ b/Sources/ManaAuthUI/Reset/ResetPasswordViewModel.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Verify/EmailVerifyGateViewModel.swift b/Sources/ManaAuthUI/Verify/EmailVerifyGateViewModel.swift new file mode 100644 index 0000000..688eb52 --- /dev/null +++ b/Sources/ManaAuthUI/Verify/EmailVerifyGateViewModel.swift @@ -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)) + } + } +} diff --git a/Sources/ManaAuthUI/Verify/ManaEmailVerifyGateView.swift b/Sources/ManaAuthUI/Verify/ManaEmailVerifyGateView.swift new file mode 100644 index 0000000..0265a9d --- /dev/null +++ b/Sources/ManaAuthUI/Verify/ManaEmailVerifyGateView.swift @@ -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) + } + } + } +} diff --git a/Tests/ManaAuthUITests/AccountViewModelTests.swift b/Tests/ManaAuthUITests/AccountViewModelTests.swift new file mode 100644 index 0000000..20e8944 --- /dev/null +++ b/Tests/ManaAuthUITests/AccountViewModelTests.swift @@ -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) + } +} diff --git a/Tests/ManaAuthUITests/EmailVerifyGateViewModelTests.swift b/Tests/ManaAuthUITests/EmailVerifyGateViewModelTests.swift new file mode 100644 index 0000000..84caee9 --- /dev/null +++ b/Tests/ManaAuthUITests/EmailVerifyGateViewModelTests.swift @@ -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)") + } + } +} diff --git a/Tests/ManaAuthUITests/ForgotResetViewModelTests.swift b/Tests/ManaAuthUITests/ForgotResetViewModelTests.swift new file mode 100644 index 0000000..e8e06fb --- /dev/null +++ b/Tests/ManaAuthUITests/ForgotResetViewModelTests.swift @@ -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) + } +} diff --git a/Tests/ManaAuthUITests/LoginViewModelTests.swift b/Tests/ManaAuthUITests/LoginViewModelTests.swift new file mode 100644 index 0000000..ea85979 --- /dev/null +++ b/Tests/ManaAuthUITests/LoginViewModelTests.swift @@ -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 == "") + } +} diff --git a/Tests/ManaAuthUITests/ManaBrandConfigTests.swift b/Tests/ManaAuthUITests/ManaBrandConfigTests.swift new file mode 100644 index 0000000..7b7d310 --- /dev/null +++ b/Tests/ManaAuthUITests/ManaBrandConfigTests.swift @@ -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") + } +} diff --git a/Tests/ManaAuthUITests/MockURLProtocol.swift b/Tests/ManaAuthUITests/MockURLProtocol.swift new file mode 100644 index 0000000..70a781c --- /dev/null +++ b/Tests/ManaAuthUITests/MockURLProtocol.swift @@ -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) +} diff --git a/Tests/ManaAuthUITests/SignUpViewModelTests.swift b/Tests/ManaAuthUITests/SignUpViewModelTests.swift new file mode 100644 index 0000000..7defda6 --- /dev/null +++ b/Tests/ManaAuthUITests/SignUpViewModelTests.swift @@ -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)") + } + } +}