diff --git a/.gitignore b/.gitignore index 1821213..89631fb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ *.xcodeproj Package.resolved .DS_Store -build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb2766..2648756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,28 +6,6 @@ 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 deleted file mode 100644 index e925cb6..0000000 --- a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift +++ /dev/null @@ -1,310 +0,0 @@ -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