import CoreImage.CIFilterBuiltins import ManaCore import SwiftUI #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif /// Account-Sheet: TOTP-2FA aktivieren. 3-Phasen-Wizard. /// /// 1. Passwort eingeben (Re-Auth) /// 2. QR-Code mit Authenticator-App scannen + Test-Code eingeben /// 3. Backup-Codes anzeigen und vom User bestätigen lassen public struct ManaTwoFactorEnrollView: View { @Environment(\.manaBrand) private var brand @State private var model: TwoFactorEnrollmentViewModel private let onDone: () -> Void public init(auth: AuthClient, onDone: @escaping () -> Void) { _model = State(initialValue: TwoFactorEnrollmentViewModel(auth: auth)) self.onDone = onDone } public var body: some View { ManaAuthScaffold(showsHeader: false) { switch model.phase { case .password: passwordPhase case let .verify(uri, _): verifyPhase(uri: uri) case let .backupCodes(codes): backupCodesPhase(codes: codes) } } } // MARK: - Phase 1: Password @ViewBuilder private var passwordPhase: some View { VStack(spacing: 16) { Image(systemName: "lock.shield") .font(.system(size: 56, weight: .light)) .foregroundStyle(brand.primary) Text("Zwei-Faktor aktivieren") .font(.title2) .fontWeight(.semibold) .foregroundStyle(brand.foreground) Text("Schütze deinen Account mit einem zusätzlichen Code. Bestätige dazu erst dein Passwort.") .font(.subheadline) .foregroundStyle(brand.mutedForeground) .multilineTextAlignment(.center) ManaSecureField( "Passwort", text: $model.password, textContentType: .password ) ManaPrimaryButton( "Weiter", isLoading: model.isWorking, isEnabled: model.canSubmitPassword ) { Task { await model.enrollWithPassword() } } if case let .error(message) = model.status { Text(message) .font(.footnote) .foregroundStyle(brand.error) .multilineTextAlignment(.center) } Button("Abbrechen", action: onDone) .font(.subheadline) .foregroundStyle(brand.mutedForeground) .padding(.top, 12) } } // MARK: - Phase 2: QR + Verify @ViewBuilder private func verifyPhase(uri: String) -> some View { VStack(spacing: 16) { Text("Code scannen") .font(.title2) .fontWeight(.semibold) .foregroundStyle(brand.foreground) Text("Öffne deine Authenticator-App (z.B. 1Password, Aegis, Google Authenticator) und scanne diesen QR-Code.") .font(.subheadline) .foregroundStyle(brand.mutedForeground) .multilineTextAlignment(.center) qrCode(for: uri) .frame(width: 220, height: 220) .padding(8) .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) Text("Gib zur Bestätigung den 6-stelligen Code aus der App ein:") .font(.subheadline) .foregroundStyle(brand.foreground) .multilineTextAlignment(.center) .padding(.top, 8) ManaTextField("123 456", text: $model.verifyCode) .autocorrectionDisabled() .font(.system(.title3, design: .monospaced)) #if os(iOS) .keyboardType(.numberPad) .textInputAutocapitalization(.never) #endif ManaPrimaryButton( "Weiter zu Backup-Codes", isEnabled: model.canSubmitVerify ) { model.confirmVerify() } Button("Abbrechen", action: onDone) .font(.subheadline) .foregroundStyle(brand.mutedForeground) .padding(.top, 12) } } // MARK: - Phase 3: Backup-Codes @ViewBuilder private func backupCodesPhase(codes: [String]) -> some View { VStack(spacing: 16) { Image(systemName: "checkmark.shield.fill") .font(.system(size: 56, weight: .light)) .foregroundStyle(brand.success) Text("Zwei-Faktor aktiv") .font(.title2) .fontWeight(.semibold) .foregroundStyle(brand.foreground) Text("Sichere diese Backup-Codes JETZT. Du brauchst sie wenn du dein Authenticator-Gerät verlierst. Jeder Code lässt sich nur einmal verwenden.") .font(.subheadline) .foregroundStyle(brand.mutedForeground) .multilineTextAlignment(.center) VStack(spacing: 6) { ForEach(codes, 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(codes.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) } } // MARK: - QR-Code /// Rendert eine `otpauth://`-URI als QR-Code via `CoreImage`. Auf /// iOS/macOS sind `CIFilter.qrCodeGenerator()` system-bordmittel. @ViewBuilder private func qrCode(for content: String) -> some View { if let cgImage = makeQRCode(from: content) { #if canImport(UIKit) Image(uiImage: UIImage(cgImage: cgImage)) .interpolation(.none) .resizable() .scaledToFit() #elseif canImport(AppKit) Image(nsImage: NSImage(cgImage: cgImage, size: NSSize(width: 220, height: 220))) .interpolation(.none) .resizable() .scaledToFit() #else Text(content) .font(.system(.caption, design: .monospaced)) #endif } else { Text("QR-Code konnte nicht generiert werden — bitte URI manuell kopieren:\n\(content)") .font(.system(.caption, design: .monospaced)) .foregroundStyle(brand.mutedForeground) } } private func makeQRCode(from string: String) -> CGImage? { let context = CIContext() let filter = CIFilter.qrCodeGenerator() filter.message = Data(string.utf8) filter.correctionLevel = "M" guard let output = filter.outputImage else { return nil } // Upscale damit der QR-Code scharf bleibt (kein anti-aliasing). let transform = CGAffineTransform(scaleX: 10, y: 10) let scaled = output.transformed(by: transform) return context.createCGImage(scaled, from: scaled.extent) } 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 } } /// Account-Sheet: TOTP-2FA wieder deaktivieren. Einfacher Single-Step /// mit Passwort-Re-Auth. public struct ManaTwoFactorDisableView: View { @Environment(\.manaBrand) private var brand @State private var password: String = "" @State private var status: DisableStatus = .idle private let auth: AuthClient private let onDone: () -> Void public init(auth: AuthClient, onDone: @escaping () -> Void) { self.auth = auth self.onDone = onDone } private enum DisableStatus: 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: "lock.shield") .font(.system(size: 56, weight: .light)) .foregroundStyle(brand.mutedForeground) Text("Zwei-Faktor deaktivieren") .font(.title2) .fontWeight(.semibold) .foregroundStyle(brand.foreground) Text("Dein Account wird wieder nur mit Email + Passwort geschützt. Backup-Codes verlieren ihre Gültigkeit.") .font(.subheadline) .foregroundStyle(brand.mutedForeground) .multilineTextAlignment(.center) ManaSecureField("Passwort", text: $password, textContentType: .password) ManaPrimaryButton( "2FA deaktivieren", role: .destructive, 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: "lock.open") .font(.system(size: 56, weight: .light)) .foregroundStyle(brand.mutedForeground) Text("Zwei-Faktor deaktiviert") .font(.title2) .fontWeight(.semibold) .foregroundStyle(brand.foreground) ManaPrimaryButton("Fertig") { onDone() } .padding(.top, 16) } } private func submit() async { guard !password.isEmpty else { return } status = .working do { try await auth.disableTotp(password: password) password = "" status = .done } catch let error as AuthError { status = .error(error.errorDescription ?? "Deaktivieren fehlgeschlagen") } catch { status = .error(String(describing: error)) } } }