mana-swift-ui/Sources/ManaAuthUI/Login/ManaLoginView.swift
Till JS c1555565b6 v0.3.0 — ManaTwoFactorChallengeView
Apps mit aktivem 2FA bekommen jetzt eine native Challenge-View nach
Email/Password-Login. ManaLoginView schaltet automatisch um wenn
AuthClient.status auf .twoFactorRequired wechselt.

Components:
- ManaTwoFactorChallengeView — Scaffold-View mit 6-stelligem Code-
  Input, Backup-Code-Toggle, Cancel zurück zum Login
- TwoFactorChallengeViewModel — @Observable State-Maschine, wraps
  AuthClient.verifyTotp/verifyBackupCode
- LoginViewModel.Status.twoFactorRequired(email:) als neuer Case;
  submit() routet automatisch dorthin wenn der AuthClient den
  Challenge-Status zurückgibt

6 neue Tests, 39/39 grün.

Setzt mana-swift-core ≥ 1.3.0 voraus.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:20:30 +02:00

106 lines
3.5 KiB
Swift

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() }
)
case .twoFactorRequired:
ManaTwoFactorChallengeView(
auth: auth,
onCancel: {
// Abbruch: User will zurück zum Email/Password-Form.
// AuthClient.status zurücksetzen damit der Challenge-
// Token verworfen wird; UI-Status auf idle.
Task { await auth.signOut(keepGuestMode: true) }
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)
}
}
}