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>
323 lines
11 KiB
Swift
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
|