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>
101 lines
3.8 KiB
Swift
101 lines
3.8 KiB
Swift
import ManaCore
|
|
import SwiftUI
|
|
|
|
/// Wird angezeigt, wenn nach erfolgreichem Email/PW-`signIn` der
|
|
/// `AuthClient.status` auf ``AuthClient/Status/twoFactorRequired(token:methods:email:)``
|
|
/// gewechselt ist. Bietet TOTP-Code-Eingabe (6-stellig) plus einen
|
|
/// Fallback auf Backup-Codes.
|
|
///
|
|
/// Apps müssen das selbst nicht einbauen — ``ManaLoginView`` schaltet
|
|
/// automatisch um. Nur direkt nötig wenn die App eine eigene Login-
|
|
/// UI-Maschine hat (z.B. Memoros AccountView).
|
|
public struct ManaTwoFactorChallengeView: View {
|
|
@Environment(\.manaBrand) private var brand
|
|
@State private var model: TwoFactorChallengeViewModel
|
|
private let onCancel: () -> Void
|
|
|
|
/// - Parameters:
|
|
/// - auth: gemeinsamer `AuthClient` der App (Status muss bereits
|
|
/// `.twoFactorRequired` sein).
|
|
/// - onCancel: Callback wenn der User "Abbrechen" drückt. Apps
|
|
/// setzen den AuthClient typischerweise auf `.signedOut`
|
|
/// zurück und zeigen wieder die Login-View.
|
|
public init(
|
|
auth: AuthClient,
|
|
onCancel: @escaping () -> Void
|
|
) {
|
|
_model = State(initialValue: TwoFactorChallengeViewModel(auth: auth))
|
|
self.onCancel = onCancel
|
|
}
|
|
|
|
public var body: some View {
|
|
ManaAuthScaffold(showsHeader: false) {
|
|
VStack(spacing: 20) {
|
|
Image(systemName: "lock.shield.fill")
|
|
.font(.system(size: 56, weight: .light))
|
|
.foregroundStyle(brand.primary)
|
|
|
|
Text(model.mode == .totp ? "Zwei-Faktor-Code" : "Backup-Code")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(brand.foreground)
|
|
|
|
Text(promptText)
|
|
.font(.subheadline)
|
|
.foregroundStyle(brand.mutedForeground)
|
|
.multilineTextAlignment(.center)
|
|
|
|
ManaTextField(placeholderText, text: $model.code)
|
|
.autocorrectionDisabled()
|
|
.font(.system(.title3, design: .monospaced))
|
|
#if os(iOS)
|
|
.keyboardType(model.mode == .totp ? .numberPad : .asciiCapable)
|
|
.textInputAutocapitalization(model.mode == .totp ? .never : .characters)
|
|
#endif
|
|
|
|
ManaPrimaryButton(
|
|
"Bestätigen",
|
|
isLoading: model.isVerifying,
|
|
isEnabled: model.canSubmit
|
|
) {
|
|
Task { await model.submit() }
|
|
}
|
|
|
|
if case let .error(message) = model.status {
|
|
Text(message)
|
|
.font(.footnote)
|
|
.foregroundStyle(brand.error)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
Button(action: { model.toggleMode() }) {
|
|
Text(model.mode == .totp
|
|
? "Stattdessen Backup-Code verwenden"
|
|
: "Stattdessen 6-stelligen Code verwenden"
|
|
)
|
|
.font(.footnote)
|
|
.foregroundStyle(brand.primary)
|
|
}
|
|
.padding(.top, 12)
|
|
|
|
Button("Abbrechen", action: onCancel)
|
|
.font(.subheadline)
|
|
.foregroundStyle(brand.mutedForeground)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var promptText: String {
|
|
switch model.mode {
|
|
case .totp:
|
|
"Öffne deine Authenticator-App und gib den 6-stelligen Code für deinen Account ein."
|
|
case .backupCode:
|
|
"Gib einen deiner einmal-nutzbaren Backup-Codes ein. Jeder Code lässt sich nur einmal verwenden."
|
|
}
|
|
}
|
|
|
|
private var placeholderText: String {
|
|
model.mode == .totp ? "123 456" : "xxxx-xxxx"
|
|
}
|
|
}
|