import ManaCore 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 { Group { 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) } } .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) } } @ViewBuilder 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) } @ViewBuilder 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 } } } @ViewBuilder 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) } } @ViewBuilder 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(cornerRadius: 6)) } } .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(cornerRadius: 8)) .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