Macht den 2FA-Vollausbau in der AccountView nutzbar. Setzt mana-swift-core ≥ 1.5.0 voraus. ManaTwoFactorAccountRow — Drop-in für AccountView: - Holt 2FA-Status via AuthClient.getProfile() - Off → "Zwei-Faktor aktivieren" → ManaTwoFactorEnrollView - An → "Zwei-Faktor aktiv" + "Backup-Codes erneuern" + "Deaktivieren" ManaBackupCodeRegenerateView — Re-Auth via Passwort, zeigt neue Backup-Codes mit Copy-to-Clipboard. TwoFactorAccountRowModel — internes @Observable-VM, reloaded Status nach Enroll/Disable/Regenerate. Plus: .gitignore um build/ erweitert (Xcode-build/ war vorher nicht abgedeckt, nur Swift-Package-.build/). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
310 lines
9.9 KiB
Swift
310 lines
9.9 KiB
Swift
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
|