v0.5.0 — ManaTwoFactorAccountRow + ManaBackupCodeRegenerateView
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>
This commit is contained in:
parent
dc8e5a4e9b
commit
971d0ff458
3 changed files with 333 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@
|
|||
*.xcodeproj
|
||||
Package.resolved
|
||||
.DS_Store
|
||||
build/
|
||||
|
|
|
|||
22
CHANGELOG.md
22
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
|
||||
|
|
|
|||
310
Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift
Normal file
310
Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue