mana-swift-ui/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift
Till JS dc8e5a4e9b v0.4.0 — ManaTwoFactorEnrollView + ManaTwoFactorDisableView
3-Phasen-Wizard für 2FA-Enrollment + Single-Step-Sheet für Disable.
Setzt mana-swift-core ≥ 1.4.0 voraus.

ManaTwoFactorEnrollView:
1. Passwort-Re-Auth → server liefert otpauth-URI + Backup-Codes
2. QR-Code-Display (CoreImage.CIFilter.qrCodeGenerator) + 6-stellige
   Test-Code-Eingabe
3. Backup-Codes-Liste mit Copy-to-Clipboard

ManaTwoFactorDisableView:
- Re-Auth via Passwort, destructive-Button, .done-Konfirmation

5 neue Tests, 44/44 grün.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:39:03 +02:00

335 lines
11 KiB
Swift

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))
}
}
}