mana-swift-ui/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift
Till JS cba31b3e3f 2FA-Status-Flackern fixen: .task raus aus Group-Modifier-Verteilung
Im Konto-Tab aller nativen Apps sprang der 2FA-Text endlos zwischen
"2FA-Status lädt…" und "2FA aktivieren", teils "Netzwerkverbindung
abgebrochen". Ursache: .task/.sheet lagen auf einem Group, der einen
zustandsabhängigen switch umschloss. Group verteilt Modifier auf jeden
Zweig — beim State-Wechsel .loading -> .loaded wechselt der Zweig, das
.task hängt sich an die neue View und feuert reload() erneut
(Endlosschleife; der in-flight getProfile() wird beim Teardown gecancelt
-> URLError.cancelled -> "abgebrochen").

- ManaTwoFactorAccountRow: switch in eine @ViewBuilder-Property
  (stateContent) extrahiert; .task/.sheet wrappen von außen -> stabile
  Identität, .task feuert einmal pro Erscheinen.
- ManaLLMSettingsView: dieselbe Falle (Group mit 3 Membern) — .task an
  die stabile Backend-Picker-Section gehängt statt an den Group.

Verifiziert via `swift build` (beide Module grün). Voller App-Build
steht aus (Mac-Platte voll, mlx-swift-C++).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:37:24 +02:00

323 lines
11 KiB
Swift

import ManaCore
import ManaThemeUI
import ManaTokens
import Observation
import SwiftUI
/// Account-Section-Block für die 2FA-Verwaltung. Apps bauen den
/// einfach in ihre AccountView ein:
///
/// ```swift
/// ManaTwoFactorAccountRow(auth: auth)
/// .manaBrand(brand)
/// ```
///
/// Die Row holt den 2FA-Status beim ersten Erscheinen via
/// `AuthClient.getProfile()` und zeigt dann entweder:
/// - "Zwei-Faktor aktivieren" (Enroll-Sheet) bei `twoFactorEnabled == false`
/// - "Zwei-Faktor deaktivieren" + "Backup-Codes erneuern" bei `true`
///
/// Nach Enroll/Disable wird der Status automatisch neu geladen,
/// damit die Row sich konsistent updated.
@MainActor
@Observable
final class TwoFactorAccountRowModel {
enum LoadState: Equatable {
case loading
case loaded(twoFactorEnabled: Bool)
case error(String)
}
private(set) var state: LoadState = .loading
private let auth: AuthClient
init(auth: AuthClient) {
self.auth = auth
}
func reload() async {
state = .loading
do {
let profile = try await auth.getProfile()
state = .loaded(twoFactorEnabled: profile.twoFactorEnabled)
} catch let error as AuthError {
if case .notSignedIn = error {
state = .error("Nicht angemeldet")
} else {
state = .error(error.errorDescription ?? "Status konnte nicht geladen werden")
}
} catch {
state = .error(String(describing: error))
}
}
}
public struct ManaTwoFactorAccountRow: View {
@Environment(\.manaBrand) private var brand
@State private var model: TwoFactorAccountRowModel
@State private var showEnroll = false
@State private var showDisable = false
@State private var showRegenerate = false
private let auth: AuthClient
public init(auth: AuthClient) {
self.auth = auth
_model = State(initialValue: TwoFactorAccountRowModel(auth: auth))
}
public var body: some View {
// WICHTIG: `.task`/`.sheet` NICHT auf einen `Group` legen, der einen
// zustandsabhängigen `switch` umschließt. Group verteilt Modifier auf
// jeden Zweig — beim State-Wechsel (.loading → .loaded) wechselt der
// Zweig, das `.task` re-attached sich an die neue View und feuert
// erneut → `reload()`-Endlosschleife (Text flackert zwischen „lädt"
// und „aktivieren", in-flight getProfile wird gecancelt →
// „Netzwerkverbindung abgebrochen"). Hier auf die aus `stateContent`
// extrahierte `some View` angewandt, wrappt der Modifier von AUSSEN um
// den Conditional → stabile Identität, `.task` feuert einmal pro
// Erscheinen (und korrekt erneut bei echtem Re-Appear).
stateContent
.task {
await model.reload()
}
.sheet(isPresented: $showEnroll, onDismiss: {
Task { await model.reload() }
}) {
ManaTwoFactorEnrollView(auth: auth, onDone: { showEnroll = false })
.manaBrand(brand)
}
.sheet(isPresented: $showDisable, onDismiss: {
Task { await model.reload() }
}) {
ManaTwoFactorDisableView(auth: auth, onDone: { showDisable = false })
.manaBrand(brand)
}
.sheet(isPresented: $showRegenerate, onDismiss: {
Task { await model.reload() }
}) {
ManaBackupCodeRegenerateView(auth: auth, onDone: { showRegenerate = false })
.manaBrand(brand)
}
}
/// Zustandsabhängiger Inhalt der Row. Bewusst als `@ViewBuilder`-Property
/// (statt `Group`), damit das `body`-`.task` von außen wrappt statt sich
/// pro `switch`-Zweig neu anzuhängen — siehe Kommentar in `body`.
@ViewBuilder
private var stateContent: some View {
switch model.state {
case .loading:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("2FA-Status lädt…")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
case let .loaded(enabled):
if enabled {
enabledRow
} else {
disabledRow
}
case let .error(message):
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
}
}
private var disabledRow: some View {
Button(action: { showEnroll = true }) {
HStack {
Image(systemName: "lock.shield")
.foregroundStyle(brand.mutedForeground)
VStack(alignment: .leading, spacing: 2) {
Text("Zwei-Faktor aktivieren")
.foregroundStyle(brand.foreground)
Text("TOTP-App mit Backup-Codes")
.font(.caption)
.foregroundStyle(brand.mutedForeground)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(brand.mutedForeground)
}
}
.buttonStyle(.plain)
}
private var enabledRow: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(brand.success)
Text("Zwei-Faktor aktiv")
.foregroundStyle(brand.foreground)
Spacer()
}
Button(action: { showRegenerate = true }) {
Text("Backup-Codes erneuern")
.font(.subheadline)
.foregroundStyle(brand.primary)
}
.buttonStyle(.plain)
Button(role: .destructive, action: { showDisable = true }) {
Text("Zwei-Faktor deaktivieren")
.font(.subheadline)
.foregroundStyle(brand.error)
}
.buttonStyle(.plain)
}
}
}
/// Sheet zum Erneuern der Backup-Codes. Re-Auth via Passwort,
/// zeigt danach die neuen Codes.
public struct ManaBackupCodeRegenerateView: View {
@Environment(\.manaBrand) private var brand
@State private var password: String = ""
@State private var newCodes: [String] = []
@State private var status: Status = .idle
private let auth: AuthClient
private let onDone: () -> Void
public init(auth: AuthClient, onDone: @escaping () -> Void) {
self.auth = auth
self.onDone = onDone
}
private enum Status: Equatable {
case idle
case working
case done
case error(String)
}
public var body: some View {
ManaAuthScaffold(showsHeader: false) {
switch status {
case .done:
doneView
default:
formView
}
}
}
private var formView: some View {
VStack(spacing: 16) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.primary)
Text("Backup-Codes erneuern")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
Text("Die alten Codes werden ungültig. Bestätige mit deinem Passwort.")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
ManaSecureField("Passwort", text: $password, textContentType: .password)
ManaPrimaryButton(
"Neue Codes generieren",
isLoading: status == .working,
isEnabled: !password.isEmpty && status != .working
) {
Task { await submit() }
}
if case let .error(message) = status {
Text(message)
.font(.footnote)
.foregroundStyle(brand.error)
.multilineTextAlignment(.center)
}
Button("Abbrechen", action: onDone)
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.padding(.top, 12)
}
}
private var doneView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56, weight: .light))
.foregroundStyle(brand.success)
Text("Neue Codes generiert")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(brand.foreground)
Text("Sichere diese Codes JETZT. Alte Codes sind ungültig.")
.font(.subheadline)
.foregroundStyle(brand.mutedForeground)
.multilineTextAlignment(.center)
VStack(spacing: 6) {
ForEach(newCodes, id: \.self) { code in
Text(code)
.font(.system(.body, design: .monospaced))
.foregroundStyle(brand.foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(brand.surface, in: RoundedRectangle.mana(Radius.sm))
}
}
.padding(.vertical, 8)
Button(action: { copyToClipboard(newCodes.joined(separator: "\n")) }) {
Label("Alle Codes kopieren", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(brand.surface, in: RoundedRectangle.mana(Radius.md))
.foregroundStyle(brand.primary)
}
.buttonStyle(.plain)
ManaPrimaryButton("Fertig — Codes sind gesichert") {
onDone()
}
.padding(.top, 12)
}
}
private func submit() async {
status = .working
do {
newCodes = try await auth.regenerateBackupCodes(password: password)
password = ""
status = .done
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Erneuerung fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
private func copyToClipboard(_ text: String) {
#if canImport(UIKit)
UIPasteboard.general.string = text
#elseif canImport(AppKit)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
}
}
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif