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
117538f77a
3 changed files with 333 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@
|
||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
Package.resolved
|
Package.resolved
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
build/
|
||||||
|
|
|
||||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -6,6 +6,28 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.4.0] — 2026-05-14
|
||||||
|
|
||||||
Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core
|
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