From 117538f77a2e95b24010d995c07d93efe1a27708 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 01:08:41 +0200 Subject: [PATCH] =?UTF-8?q?v0.5.0=20=E2=80=94=20ManaTwoFactorAccountRow=20?= =?UTF-8?q?+=20ManaBackupCodeRegenerateView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + CHANGELOG.md | 22 ++ .../TwoFactor/ManaTwoFactorAccountRow.swift | 310 ++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift diff --git a/.gitignore b/.gitignore index 89631fb..1821213 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.xcodeproj Package.resolved .DS_Store +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2648756..6bb2766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.5.0] — 2026-05-14 + +Minor — `ManaTwoFactorAccountRow` + `ManaBackupCodeRegenerateView`. +Macht den 2FA-Vollausbau in der AccountView nutzbar. Setzt +mana-swift-core ≥ 1.5.0 voraus (`getProfile()`). + +### Neu + +- `ManaTwoFactorAccountRow` — Drop-in für AccountView. Holt den + 2FA-Status via `AuthClient.getProfile()` und zeigt: + - **Off:** "Zwei-Faktor aktivieren" → öffnet `ManaTwoFactorEnrollView` + - **An:** "Zwei-Faktor aktiv" + "Backup-Codes erneuern" + + "Zwei-Faktor deaktivieren" +- `ManaBackupCodeRegenerateView` — Re-Auth via Passwort, zeigt neue + Backup-Codes + Copy-to-Clipboard. +- `TwoFactorAccountRowModel` — internes `@Observable`-VM, reloaded + Status nach Enroll/Disable/Regenerate. + +Damit ist 2FA in den Apps end-to-end nutzbar — User kann aktivieren, +Backup-Codes verwalten, deaktivieren. Der Login-Flow ist seit v0.3.0 +durchgängig. + ## [0.4.0] — 2026-05-14 Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift new file mode 100644 index 0000000..e925cb6 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift @@ -0,0 +1,310 @@ +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