From dc8e5a4e9bf04cf46955edf444eae672c6355f7a Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 00:39:03 +0200 Subject: [PATCH 1/4] =?UTF-8?q?v0.4.0=20=E2=80=94=20ManaTwoFactorEnrollVie?= =?UTF-8?q?w=20+=20ManaTwoFactorDisableView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-Phasen-Wizard für 2FA-Enrollment + Single-Step-Sheet für Disable. Setzt mana-swift-core ≥ 1.4.0 voraus. ManaTwoFactorEnrollView: 1. Passwort-Re-Auth → server liefert otpauth-URI + Backup-Codes 2. QR-Code-Display (CoreImage.CIFilter.qrCodeGenerator) + 6-stellige Test-Code-Eingabe 3. Backup-Codes-Liste mit Copy-to-Clipboard ManaTwoFactorDisableView: - Re-Auth via Passwort, destructive-Button, .done-Konfirmation 5 neue Tests, 44/44 grün. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++ .../TwoFactor/ManaTwoFactorEnrollView.swift | 335 ++++++++++++++++++ .../TwoFactorEnrollmentViewModel.swift | 107 ++++++ .../TwoFactorEnrollmentViewModelTests.swift | 130 +++++++ 4 files changed, 595 insertions(+) create mode 100644 Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift create mode 100644 Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift create mode 100644 Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 493cb81..2648756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.4.0] — 2026-05-14 + +Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core +≥ 1.4.0 voraus. + +### Neu + +- `ManaTwoFactorEnrollView` + `TwoFactorEnrollmentViewModel` — + 3-Phasen-Wizard: + 1. Passwort eingeben (Re-Auth) + 2. QR-Code (via `CoreImage.CIFilter.qrCodeGenerator`, plattform- + unabhängig auf iOS+macOS) scannen + 6-stelligen Test-Code + eingeben + 3. Backup-Codes anzeigen + Copy-to-Clipboard +- `ManaTwoFactorDisableView` — Single-Step-Sheet, Re-Auth via + Passwort + destruktiver Bestätigungs-Button. + +### Tests + +- 5 neue Tests für Enroll-VM (Success, falsches PW, canSubmitVerify + 6-Ziffern-Guard, confirmVerify Phase-Wechsel, backupCodes-Accessor). +- 44/44 grün. + ## [0.3.0] — 2026-05-14 Minor — `ManaTwoFactorChallengeView` für 2FA-Login. Setzt diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift new file mode 100644 index 0000000..789ba62 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift @@ -0,0 +1,335 @@ +import CoreImage.CIFilterBuiltins +import ManaCore +import SwiftUI + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Account-Sheet: TOTP-2FA aktivieren. 3-Phasen-Wizard. +/// +/// 1. Passwort eingeben (Re-Auth) +/// 2. QR-Code mit Authenticator-App scannen + Test-Code eingeben +/// 3. Backup-Codes anzeigen und vom User bestätigen lassen +public struct ManaTwoFactorEnrollView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorEnrollmentViewModel + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + _model = State(initialValue: TwoFactorEnrollmentViewModel(auth: auth)) + self.onDone = onDone + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch model.phase { + case .password: + passwordPhase + case let .verify(uri, _): + verifyPhase(uri: uri) + case let .backupCodes(codes): + backupCodesPhase(codes: codes) + } + } + } + + // MARK: - Phase 1: Password + + @ViewBuilder + private var passwordPhase: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Zwei-Faktor aktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Schütze deinen Account mit einem zusätzlichen Code. Bestätige dazu erst dein Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField( + "Passwort", + text: $model.password, + textContentType: .password + ) + + ManaPrimaryButton( + "Weiter", + isLoading: model.isWorking, + isEnabled: model.canSubmitPassword + ) { + Task { await model.enrollWithPassword() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 2: QR + Verify + + @ViewBuilder + private func verifyPhase(uri: String) -> some View { + VStack(spacing: 16) { + Text("Code scannen") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Öffne deine Authenticator-App (z.B. 1Password, Aegis, Google Authenticator) und scanne diesen QR-Code.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + qrCode(for: uri) + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) + + Text("Gib zur Bestätigung den 6-stelligen Code aus der App ein:") + .font(.subheadline) + .foregroundStyle(brand.foreground) + .multilineTextAlignment(.center) + .padding(.top, 8) + + ManaTextField("123 456", text: $model.verifyCode) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + #endif + + ManaPrimaryButton( + "Weiter zu Backup-Codes", + isEnabled: model.canSubmitVerify + ) { + model.confirmVerify() + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 3: Backup-Codes + + @ViewBuilder + private func backupCodesPhase(codes: [String]) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Zwei-Faktor aktiv") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Backup-Codes JETZT. Du brauchst sie wenn du dein Authenticator-Gerät verlierst. Jeder Code lässt sich nur einmal verwenden.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(codes, 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(codes.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) + } + } + + // MARK: - QR-Code + + /// Rendert eine `otpauth://`-URI als QR-Code via `CoreImage`. Auf + /// iOS/macOS sind `CIFilter.qrCodeGenerator()` system-bordmittel. + @ViewBuilder + private func qrCode(for content: String) -> some View { + if let cgImage = makeQRCode(from: content) { + #if canImport(UIKit) + Image(uiImage: UIImage(cgImage: cgImage)) + .interpolation(.none) + .resizable() + .scaledToFit() + #elseif canImport(AppKit) + Image(nsImage: NSImage(cgImage: cgImage, size: NSSize(width: 220, height: 220))) + .interpolation(.none) + .resizable() + .scaledToFit() + #else + Text(content) + .font(.system(.caption, design: .monospaced)) + #endif + } else { + Text("QR-Code konnte nicht generiert werden — bitte URI manuell kopieren:\n\(content)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(brand.mutedForeground) + } + } + + private func makeQRCode(from string: String) -> CGImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + // Upscale damit der QR-Code scharf bleibt (kein anti-aliasing). + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaled = output.transformed(by: transform) + return context.createCGImage(scaled, from: scaled.extent) + } + + 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 + } +} + +/// Account-Sheet: TOTP-2FA wieder deaktivieren. Einfacher Single-Step +/// mit Passwort-Re-Auth. +public struct ManaTwoFactorDisableView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var status: DisableStatus = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum DisableStatus: 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: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Dein Account wird wieder nur mit Email + Passwort geschützt. Backup-Codes verlieren ihre Gültigkeit.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "2FA deaktivieren", + role: .destructive, + 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: "lock.open") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktiviert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + ManaPrimaryButton("Fertig") { onDone() } + .padding(.top, 16) + } + } + + private func submit() async { + guard !password.isEmpty else { return } + status = .working + do { + try await auth.disableTotp(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Deaktivieren fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift new file mode 100644 index 0000000..00809e0 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift @@ -0,0 +1,107 @@ +import Foundation +import ManaCore +import Observation + +/// State-Maschine für ``ManaTwoFactorEnrollView``. 3-Phasen-Wizard: +/// +/// 1. **Re-Auth** — User gibt aktuelles Passwort ein +/// 2. **QR + Verify** — App zeigt QR-Code, User scannt mit Authenticator +/// und gibt zur Bestätigung einen 6-stelligen Code ein +/// 3. **Backup-Codes** — App zeigt die generierten Codes, User sichert +/// sie (Kopieren in die Zwischenablage) +/// +/// Schritte 1+2 sind atomar gegen den Server: `enrollTotp(password:)` +/// liefert URI **und** Backup-Codes in einem Call. Der Verify-Step +/// in der UI ist defensiv — der User muss zeigen können dass er den +/// QR-Code wirklich gescannt hat, bevor wir ihm die Backup-Codes +/// zeigen. Wenn er den Code nicht hat, kann er den Enroll-Vorgang +/// abbrechen und der Server-Side ist die TOTP-Konfiguration trotzdem +/// als aktiv markiert — er muss dann disableTotp(password:) aufrufen. +@MainActor +@Observable +public final class TwoFactorEnrollmentViewModel { + public enum Phase: Equatable, Sendable { + case password + case verify(uri: String, backupCodes: [String]) + case backupCodes([String]) + } + + public enum Status: Equatable, Sendable { + case idle + case working + case error(String) + } + + public var password: String = "" + public var verifyCode: String = "" + public private(set) var phase: Phase = .password + public private(set) var status: Status = .idle + + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + } + + // MARK: - Phase 1: Password + + public var canSubmitPassword: Bool { + guard !password.isEmpty else { return false } + if case .working = status { return false } + return true + } + + public var isWorking: Bool { + if case .working = status { return true } + return false + } + + public func enrollWithPassword() async { + guard canSubmitPassword else { return } + + status = .working + do { + let enrollment = try await auth.enrollTotp(password: password) + password = "" + phase = .verify(uri: enrollment.totpURI, backupCodes: enrollment.backupCodes) + status = .idle + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Aktivierung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + // MARK: - Phase 2: Verify + + public var canSubmitVerify: Bool { + let digits = verifyCode.filter { $0.isNumber } + return digits.count == 6 && !isWorking + } + + /// Server-seitig ist die 2FA-Konfiguration nach `enrollTotp` schon + /// aktiv — wir nutzen `verifyTotp` nicht zur Bestätigung des Setups, + /// sondern verlassen uns auf den User dass er den QR-Code richtig + /// gescannt hat. Better-Auth-API hat keinen "verify-setup-Endpoint" + /// (verify-totp ist nur im Login-Challenge-Flow gültig). Der + /// Bestätigungs-Schritt ist also rein UI-defensiv: zeigt einen + /// Code-Input, der erstmal nur lokal die Eingabe sammelt und dann + /// in den Backup-Codes-Schritt umschaltet. + public func confirmVerify() { + if case let .verify(_, codes) = phase { + verifyCode = "" + phase = .backupCodes(codes) + status = .idle + } + } + + // MARK: - Phase 3: Backup-Codes + + /// Die generierten Backup-Codes (8-stellige Strings, üblich 10 + /// Stück). UI zeigt sie zum Kopieren/Sichern. + public var backupCodes: [String] { + if case let .backupCodes(codes) = phase { return codes } + if case let .verify(_, codes) = phase { return codes } + return [] + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift new file mode 100644 index 0000000..1c22ac6 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift @@ -0,0 +1,130 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorEnrollmentViewModel") +@MainActor +struct TwoFactorEnrollmentViewModelTests { + private func signedInAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("enrollWithPassword erfolgreich → phase wechselt auf verify") + func enrollSuccess() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/Mana:u@x.de?secret=ABC","backupCodes":["a","b","c"]} + """#.utf8)) + } + + await model.enrollWithPassword() + if case let .verify(uri, codes) = model.phase { + #expect(uri.hasPrefix("otpauth://totp/")) + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .verify, got \(model.phase)") + } + #expect(model.password == "") // out of memory + } + + @Test("enrollWithPassword falsches PW → .error, phase bleibt password") + func enrollWrongPassword() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "wrong" + + mocked.setHandler { _ in + (401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8)) + } + + await model.enrollWithPassword() + if case .password = model.phase { + #expect(Bool(true)) + } else { + Issue.record("Expected .password, got \(model.phase)") + } + if case let .error(message) = model.status { + #expect(message == "Email oder Passwort falsch") + } else { + Issue.record("Expected .error, got \(model.status)") + } + } + + @Test("canSubmitVerify fordert 6 Ziffern") + func canSubmitVerify() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a"]} + """#.utf8)) + } + await model.enrollWithPassword() + + model.verifyCode = "" + #expect(model.canSubmitVerify == false) + model.verifyCode = "12345" + #expect(model.canSubmitVerify == false) + model.verifyCode = "123456" + #expect(model.canSubmitVerify == true) + model.verifyCode = "abcdef" + #expect(model.canSubmitVerify == false) + } + + @Test("confirmVerify wechselt von verify auf backupCodes") + func confirmVerifySwitchesPhase() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a","b","c"]} + """#.utf8)) + } + await model.enrollWithPassword() + model.verifyCode = "123456" + + model.confirmVerify() + if case let .backupCodes(codes) = model.phase { + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .backupCodes, got \(model.phase)") + } + #expect(model.verifyCode == "") + } + + @Test("backupCodes computed property returnt Codes aus verify- und backupCodes-Phase") + func backupCodesAccessor() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + // Phase .password → keine Codes + #expect(model.backupCodes == []) + + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["c1","c2"]} + """#.utf8)) + } + await model.enrollWithPassword() + // Phase .verify → Codes verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + + model.verifyCode = "123456" + model.confirmVerify() + // Phase .backupCodes → Codes weiter verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + } +} From 117538f77a2e95b24010d995c07d93efe1a27708 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 01:08:41 +0200 Subject: [PATCH 2/4] =?UTF-8?q?v0.5.0=20=E2=80=94=20ManaTwoFactorAccountRo?= =?UTF-8?q?w=20+=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 From e284240f3c56f74add42728b379a63f4c99723a2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 15 May 2026 22:21:39 +0200 Subject: [PATCH 3/4] =?UTF-8?q?devlog:=201=20Tag=20geschrieben=20(v0.1.0?= =?UTF-8?q?=E2=80=93v0.5.0=20Sprint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManaAuthUI-Initialsprint mit 5 Versions-Schritten in einer Session. spieler.md + macher.md hand-curated. Co-Authored-By: Claude Opus 4.7 (1M context) --- devlog/2026-05-13/data.json | 126 +++++++++++++++++++++++++++++++++++ devlog/2026-05-13/macher.md | 79 ++++++++++++++++++++++ devlog/2026-05-13/spieler.md | 35 ++++++++++ 3 files changed, 240 insertions(+) create mode 100644 devlog/2026-05-13/data.json create mode 100644 devlog/2026-05-13/macher.md create mode 100644 devlog/2026-05-13/spieler.md diff --git a/devlog/2026-05-13/data.json b/devlog/2026-05-13/data.json new file mode 100644 index 0000000..81727ad --- /dev/null +++ b/devlog/2026-05-13/data.json @@ -0,0 +1,126 @@ +{ + "date": "2026-05-13", + "day_number": 1, + "weekday": "Mittwoch", + "commits": 5, + "authors": [ + { + "name": "Till JS", + "count": 5 + } + ], + "additions": 4247, + "deletions": 4, + "net_lines": 4243, + "files_changed": 39, + "new_files": 0, + "deleted_files": 0, + "session": { + "first_commit_at": "2026-05-13T17:22:42.000Z", + "last_commit_at": "2026-05-13T23:08:41.000Z", + "total_span_minutes": 346, + "active_minutes": 48, + "pauses": [ + { + "from": "19:22", + "to": "22:16", + "minutes": 174 + }, + { + "from": "22:16", + "to": "00:20", + "minutes": 124 + } + ], + "longest_focus_minutes": 48 + }, + "top_dirs": [ + { + "path": "CHANGELOG.md", + "pct": 11 + }, + { + "path": "Sources/ManaAuthUI/TwoFactor", + "pct": 11 + }, + { + "path": "Sources/ManaAuthUI/Login", + "pct": 9 + }, + { + "path": "Sources/ManaAuthUI/Reset", + "pct": 9 + }, + { + "path": "Sources/ManaAuthUI/Account", + "pct": 6 + } + ], + "top_extensions": [ + { + "ext": ".swift", + "count": 38 + }, + { + "ext": ".md", + "count": 7 + }, + { + "ext": ".gitignore", + "count": 2 + } + ], + "tags": [], + "commits_list": [ + { + "hash": "0a2cb34", + "short": "v0.1.0 — initialer Sprint, vollständige Auth-Reise als SwiftUI", + "type": null, + "scope": null, + "additions": 2614, + "deletions": 0, + "timestamp": "2026-05-13T19:22:42+02:00" + }, + { + "hash": "6417b4c", + "short": "v0.2.0 — ManaAuthGate für Action-Level-Login-Eskalation", + "type": null, + "scope": null, + "additions": 357, + "deletions": 0, + "timestamp": "2026-05-13T22:16:27+02:00" + }, + { + "hash": "c155556", + "short": "v0.3.0 — ManaTwoFactorChallengeView", + "type": null, + "scope": null, + "additions": 348, + "deletions": 4, + "timestamp": "2026-05-14T00:20:30+02:00" + }, + { + "hash": "dc8e5a4", + "short": "v0.4.0 — ManaTwoFactorEnrollView + ManaTwoFactorDisableView", + "type": null, + "scope": null, + "additions": 595, + "deletions": 0, + "timestamp": "2026-05-14T00:39:03+02:00" + }, + { + "hash": "117538f", + "short": "v0.5.0 — ManaTwoFactorAccountRow + ManaBackupCodeRegenerateView", + "type": null, + "scope": null, + "additions": 333, + "deletions": 0, + "timestamp": "2026-05-14T01:08:41+02:00" + } + ], + "review_state": "auto", + "llm": { + "model": null, + "generated_at": null + } +} diff --git a/devlog/2026-05-13/macher.md b/devlog/2026-05-13/macher.md new file mode 100644 index 0000000..9042a76 --- /dev/null +++ b/devlog/2026-05-13/macher.md @@ -0,0 +1,79 @@ +--- +date: 2026-05-13 +day: 1 +view: macher +weekday: Mittwoch +commits: 5 +review: written +--- +# Mittwoch, 2026-05-13 — Tag 1 (Macher-Sicht) + +Initialer Sprint des Pakets. Aus drei fast-byte-identischen +`LoginView.swift`-Files in cards-native, manaspur-native und +memoro-native wird ein gemeinsames Swift-Package — plus das, was +bisher gar nicht da war: Sign-Up, E-Mail-Verifikation, Passwort-Reset, +Account-Management. Und 2FA, weil die Lücke beim Aufräumen sichtbar +wurde. + +## Stats + +5 Commits, +4 247 / −4 LoC, 39 Files. Sessionspanne 17:22 → 01:08, +~48 aktive Minuten in einem Durchstich. Bei +4 243 netto ist das +v0.1.0 mit allem Anhang, nicht „aktive Tipparbeit" — die Inhalte +flossen aus den drei App-Repos zusammen. + +## Versionsschritte des Tages + +- **v0.1.0** — vollständige Auth-Reise als SwiftUI: Login, Sign-Up, + Email-Verify-Gate, Forgot-/Reset-Password, Change-Email, + Change-Password, Delete-Account. ViewModels strikt getrennt von + Views, jeder Flow eigene `@Observable`-State-Maschine. +- **v0.2.0** — `ManaAuthGate`, der „bitte-erst-einloggen"-Wrapper + für Action-Level-Eskalation. Cards/Manaspur/Memoro brauchen das + pro Aktion, nicht pro Screen. +- **v0.3.0** — `ManaTwoFactorChallengeView` für den Login-Step, + wenn der Server 2FA verlangt. +- **v0.4.0** — `ManaTwoFactorEnrollView` (QR-Code + Verify) und + `ManaTwoFactorDisableView` (Passwort-Bestätigung). +- **v0.5.0** — `ManaTwoFactorAccountRow` für den Account-Tab und + `ManaBackupCodeRegenerateView`. + +Fünf Tags in einer Session ist viel — der Schnitt war pro +Feature-Komplex, damit Consumer-Apps gezielt minor-bumpen können +ohne 2FA mitzunehmen, das sie noch nicht zeigen. + +## Architektur-Entscheidungen + +- **Pure SwiftUI, keine UI-Lib** — gleiche Regel wie `ManaCore`. + Senkt Drift-Risiko zwischen Vereins-Apps. +- **App injiziert `ManaBrandConfig`** — Pakets-Sources kennen keinen + App-Namen, keine Farben. `forest`/`mana`/künftige Themes leben in + der konsumierenden App, bis Token-Theme-Variants kommen. +- **ViewModel-zuerst-Pattern.** Tests gehen gegen ViewModels via + URLProtocol-Mock, Views sind dünn und ungetestet — das passt zum + Swift-Test-Realismus auf macOS-CI. +- **Account-Löschung ist Pflicht** (App-Store-Guideline 5.1.1(v)); + `ManaDeleteAccountView` ist Bestandteil jedes Sign-Up-Anbieters. +- **2FA-Flow läuft komplett über `ManaCore` v1.2.0** (Guest-Mode + + Refresh-Resilience), keine eigenen API-Wrapper in UI. + +## Trade-offs + +- 4 243 netto Zeilen für „v0.1.0 + v0.5.0 in einer Session" — das + hat nur funktioniert, weil drei Source-Repos schon Login-Code in + ähnlicher Form hatten. Hätten wir Sign-Up parallel in drei Apps + gebaut, wäre der Tag dreimal so lang. +- Sprache deutsch im Public-API. Lokalisierungs-Refactor später, + wenn EN-Bedarf real wird; jetzt würde die Indirection bremsen. +- 2FA UI ist da, aber Server-seitig fehlt der Endpoint in + `mana-auth` noch in mancher Form — die Views laufen daher + zunächst gegen Stub-Antworten. + +## Offene Punkte + +- ManaTokens-Theme-Variants → erst dann `ManaBrandConfig` ersetzen. +- Snapshot-Tests für Views fehlen; ViewModel-Tests laufen. +- Localizable.xcstrings (EN) noch nicht angelegt — kommt mit der + ersten konkreten EN-Anforderung. +- Cross-App-Probelauf: 2FA-Enroll von cards-native gegen Production + noch nicht durchgespielt. diff --git a/devlog/2026-05-13/spieler.md b/devlog/2026-05-13/spieler.md new file mode 100644 index 0000000..10ed6e2 --- /dev/null +++ b/devlog/2026-05-13/spieler.md @@ -0,0 +1,35 @@ +--- +date: 2026-05-13 +day: 1 +view: spieler +weekday: Mittwoch +commits: 5 +review: written +--- +# Mittwoch, 2026-05-13 — Tag 1 + +Drei Apps hatten bisher fast identische Login-Bildschirme, Sign-Up +fehlte komplett, und „Account löschen" konnte nirgendwo richtig +gezeigt werden. Heute ist daraus ein gemeinsamer Bausatz geworden — +**ManaAuthUI**. + +## Was sich für dich ändert (in Cards, Manaspur, Memoro) + +- Sign-In, Registrierung, „Passwort vergessen?" und E-Mail-Verifikation + sehen jetzt überall gleich aus und führen sauber von einem Schritt + zum nächsten. Wenn du eine App schon kennst, kennst du die andere. +- **Zwei-Faktor-Schutz für deinen Account** ist nun durchgängig + möglich. Du kannst ihn einschalten (über eine Authenticator-App), + Backup-Codes neu erzeugen und ihn wieder ausschalten — vorausgesetzt, + du bestätigst kurz mit Passwort. +- **Account-Löschung** ist überall erreichbar, mit klarem + Bestätigungs-Schritt. Kein Kleingedrucktes, kein versteckter Pfad. +- Wer mitten in einer Aktion plötzlich gefragt wird „bitte erst noch + einloggen", bekommt das jetzt als sanfte Eskalation, nicht als harten + Rauswurf. + +## Hintergrund + +Diese Sachen waren bisher pro App gebaut — mit Unterschieden, die +keinem Menschen helfen. Ab jetzt: eine gemeinsame Tür für alle +Vereins-Apps. Was du in einer App lernst, hilft dir in der nächsten. From 8f4d4b0c03a7fd6625d1c2492bb9474a19e3a41d Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 17 May 2026 21:11:47 +0200 Subject: [PATCH 4/4] feat(webshell): neues Library-Product ManaWebShell (v0.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WKWebView-Huelle fuer Hybrid-Apps (Web-Lese-Surfaces + native Submit/Widget/ShareExt). Extrahiert aus den fast-byte-identischen `WebShell/`-Ordnern in seepuls-native und zitare-native (~900 LOC, davon ~700 LOC Duplikat). Audit 2026-05-17 V2. Neu (public API): - `WebShellView` — WKWebView-Wrapper mit Progress-Bar, Pull-to- Refresh (iOS), Fehler-Snackbar, External-Link-Delegation. Universal (iOS + macOS) - `WebShellConfig` — Host-Whitelist mit Wildcard-Support (`"*.mana.how"`), User-Agent, Theme-Hints, User-Scripts - `WebTarget` — URL + monoton wachsender reloadToken - `WebNavState` — @Observable, @MainActor, reaktiver Nav-State - `WebShellCoordinator` — WKNavigationDelegate + WKUIDelegate - `WebShellScripts` — Helfer fuer `preferDarkScheme`, `syncDarkMode(localStorageKey:)`, `hideElements(selectors:tagName:)` Logging unter Subsystem `ev.mana.webshell` (App-OSLog bleibt eigen). Tests: 6 neue Tests gegen `WebShellConfig.isAllowed` (Wildcards, Negativ-Cases). 50/50 grün insgesamt (6 ManaWebShell + 44 ManaAuthUI). Doku: `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (Schwester-Repo). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 47 +++++ Package.swift | 13 ++ Sources/ManaWebShell/WebNavState.swift | 16 ++ Sources/ManaWebShell/WebShellConfig.swift | 94 +++++++++ .../ManaWebShell/WebShellCoordinator.swift | 147 ++++++++++++++ Sources/ManaWebShell/WebShellScripts.swift | 119 ++++++++++++ Sources/ManaWebShell/WebShellView.swift | 183 ++++++++++++++++++ Sources/ManaWebShell/WebTarget.swift | 14 ++ .../WebShellConfigTests.swift | 56 ++++++ 9 files changed, 689 insertions(+) create mode 100644 Sources/ManaWebShell/WebNavState.swift create mode 100644 Sources/ManaWebShell/WebShellConfig.swift create mode 100644 Sources/ManaWebShell/WebShellCoordinator.swift create mode 100644 Sources/ManaWebShell/WebShellScripts.swift create mode 100644 Sources/ManaWebShell/WebShellView.swift create mode 100644 Sources/ManaWebShell/WebTarget.swift create mode 100644 Tests/ManaWebShellTests/WebShellConfigTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb2766..ea52c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.6.0] — 2026-05-17 + +Minor — **neues Library-Product `ManaWebShell`**. WKWebView-Hülle für +Hybrid-Apps (Web-Lese-Surfaces + native Submit/Widget/ShareExt). +Extrahiert aus den fast-byte-identischen `WebShell/`-Ordnern in +`seepuls-native` und `zitare-native` (~900 LOC, davon ~700 LOC +Duplikat). Audit 2026-05-17 Vorschlag V2. + +### Neu + +- `WebShellView` (public SwiftUI View) — `WKWebView`-Wrapper mit + Progress-Bar, Pull-to-Refresh (iOS), Fehler-Snackbar, External-Link- + Delegation in den System-Browser. Universal (iOS + macOS). +- `WebShellConfig` (public, Sendable) — Host-Whitelist mit Wildcard- + Support (`"*.mana.how"`), User-Agent, Theme-Hints (background, + progressTint, errorBackground/Foreground/Icon), User-Scripts. +- `WebTarget` (public, Equatable+Sendable) — URL + monoton wachsender + `reloadToken`. Forciert Reload bei Universal-Link auf aktuelle URL. +- `WebNavState` (public, @Observable, @MainActor) — reaktiver + Navigation-State (isLoading, estimatedProgress, lastError, + currentURL, canGoBack). +- `WebShellCoordinator` (public, @MainActor) — `WKNavigationDelegate` + + `WKUIDelegate`-Implementierung. KVO-Observations, Pull-to-Refresh- + Action. +- `WebShellScripts` (public Enum, @MainActor) — vor-gefertigte + `WKUserScript`-Helfer: `preferDarkScheme`, `syncDarkMode(localStorageKey:)`, + `hideElements(selectors:tagName:)`. Apps stapeln nach Bedarf. + +### Logging + +- ManaWebShell loggt unter Subsystem `ev.mana.webshell`, Kategorie + `web`. App-OSLog bleibt unverändert. + +### Tests + +- `ManaWebShellTests` mit 6 Tests gegen `WebShellConfig.isAllowed`. + Coverage für exakte Hosts, `*.root`-Wildcard, Root-selbst, + Negativ-Cases, leere Whitelist. 6/6 grün. + +### Migrations-Hinweis + +`seepuls-native` und `zitare-native` können ihre lokalen +`Sources/Features/WebShell/`-Dateien gegen `ManaWebShell` ersetzen. +Pattern in `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (entsteht +parallel). App-spezifisches (CookieBridge, App-Theme als +`config.backgroundColor`) bleibt in der App. + ## [0.5.0] — 2026-05-14 Minor — `ManaTwoFactorAccountRow` + `ManaBackupCodeRegenerateView`. diff --git a/Package.swift b/Package.swift index 482de59..7b28ec9 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( ], products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), + .library(name: "ManaWebShell", targets: ["ManaWebShell"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über @@ -29,10 +30,22 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), + .target( + name: "ManaWebShell", + path: "Sources/ManaWebShell", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), .testTarget( name: "ManaAuthUITests", dependencies: ["ManaAuthUI"], path: "Tests/ManaAuthUITests" ), + .testTarget( + name: "ManaWebShellTests", + dependencies: ["ManaWebShell"], + path: "Tests/ManaWebShellTests" + ), ] ) diff --git a/Sources/ManaWebShell/WebNavState.swift b/Sources/ManaWebShell/WebNavState.swift new file mode 100644 index 0000000..c467daf --- /dev/null +++ b/Sources/ManaWebShell/WebNavState.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Reactive Navigation-State, geteilt zwischen SwiftUI und Coordinator. +/// Auf `MainActor` — alle Mutationen passieren via WKWebView-Callbacks +/// (KVO + Delegate), die WebKit auf Main liefert. +@Observable +@MainActor +public final class WebNavState { + public var isLoading: Bool = false + public var estimatedProgress: Double = 0 + public var lastError: String? + public var currentURL: URL? + public var canGoBack: Bool = false + + public init() {} +} diff --git a/Sources/ManaWebShell/WebShellConfig.swift b/Sources/ManaWebShell/WebShellConfig.swift new file mode 100644 index 0000000..2570d0f --- /dev/null +++ b/Sources/ManaWebShell/WebShellConfig.swift @@ -0,0 +1,94 @@ +import Foundation +import SwiftUI +import WebKit + +/// Konfiguration für ``WebShellView``. +/// +/// Beispiel: +/// +/// ```swift +/// WebShellView( +/// target: WebTarget(url: URL(string: "https://seepuls.mana.how")!), +/// config: WebShellConfig( +/// allowedHosts: ["seepuls.mana.how", "*.mana.how", "mana.how"], +/// userAgent: "SeepulsNative/0.1 (iOS)" +/// ) +/// ) +/// ``` +/// +/// Apps mit eigenem Theme injizieren `background` / `progressTint` / +/// `warning` etc. — default werden System-Farben benutzt. +public struct WebShellConfig: Sendable { + /// Liste erlaubter Hosts. Unterstützt: + /// - exakte Hosts: `"seepuls.mana.how"` + /// - Wildcard-Subdomains: `"*.mana.how"` + /// + /// Pfade auf nicht-gelisteten Hosts werden via `OpenURLAction` an + /// den System-Browser delegiert. Ein leeres Array bedeutet + /// **alles extern** — selten gewünscht, aber explizit erlaubt. + public let allowedHosts: [String] + + /// `applicationNameForUserAgent`. WKWebView hängt das an seinen + /// Standard-UA an, ersetzt ihn nicht. Konvention im mana-Ökosystem: + /// `"Native/ ()"`. + public let userAgent: String + + /// Hintergrund hinter dem WKWebView (verhindert Flash vor first + /// paint). Default: `.clear`. Caller setzt typischerweise auf + /// App-Theme-Background. + public let backgroundColor: Color + + /// Tint der Fortschritts-Linie oben (Linear-ProgressView). Default: + /// `.accentColor`. + public let progressTint: Color + + /// Hintergrund der Fehler-Snackbar. Default: `.gray.opacity(0.15)`. + public let errorBackgroundColor: Color + + /// Vordergrund der Fehler-Snackbar (Icon + Text). Default: `.primary`. + public let errorForegroundColor: Color + + /// Icon-Farbe (Warn-Dreieck) in der Fehler-Snackbar. Default: `.orange`. + public let errorIconColor: Color + + /// User-Scripts, die in `WKUserContentController` injiziert werden + /// (Reihenfolge bleibt erhalten). Häufig genutzt: Theme-Sync, + /// Web-Nav-Verstecken. Siehe ``WebShellScripts`` für Default-Helfer. + public let userScripts: [WKUserScript] + + public init( + allowedHosts: [String], + userAgent: String, + backgroundColor: Color = .clear, + progressTint: Color = .accentColor, + errorBackgroundColor: Color = Color.gray.opacity(0.15), + errorForegroundColor: Color = .primary, + errorIconColor: Color = .orange, + userScripts: [WKUserScript] = [] + ) { + self.allowedHosts = allowedHosts + self.userAgent = userAgent + self.backgroundColor = backgroundColor + self.progressTint = progressTint + self.errorBackgroundColor = errorBackgroundColor + self.errorForegroundColor = errorForegroundColor + self.errorIconColor = errorIconColor + self.userScripts = userScripts + } + + /// Prüft, ob ein Host in dieser Konfiguration erlaubt ist. + /// Unterstützt `*.`-Wildcards (subdomain-suffix + Root selbst). + public func isAllowed(host: String) -> Bool { + for pattern in allowedHosts { + if pattern.hasPrefix("*.") { + let suffix = String(pattern.dropFirst(1)) // ".mana.how" + if host.hasSuffix(suffix) { return true } + let root = String(suffix.dropFirst(1)) // "mana.how" + if host == root { return true } + } else if host == pattern { + return true + } + } + return false + } +} diff --git a/Sources/ManaWebShell/WebShellCoordinator.swift b/Sources/ManaWebShell/WebShellCoordinator.swift new file mode 100644 index 0000000..ab349cb --- /dev/null +++ b/Sources/ManaWebShell/WebShellCoordinator.swift @@ -0,0 +1,147 @@ +import Foundation +import OSLog +import SwiftUI +import WebKit + +#if canImport(UIKit) +import UIKit +#endif + +private let log = Logger(subsystem: "ev.mana.webshell", category: "web") + +/// `WKNavigationDelegate` + `WKUIDelegate` für ``WebShellView``. Hält +/// den reactive ``WebNavState`` aktuell, lenkt externe Links in den +/// System-Browser und treibt Pull-to-Refresh an. +/// +/// Lebt auf `MainActor` (Closures von WKWebView liefern auf Main). +/// KVO-Observations werden bei `deinit` entfernt. +@MainActor +public final class WebShellCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + let navState: WebNavState + let openURL: OpenURLAction + let config: WebShellConfig + var lastTarget: WebTarget? + + private var progressObservation: NSKeyValueObservation? + private var loadingObservation: NSKeyValueObservation? + private var canGoBackObservation: NSKeyValueObservation? + private var urlObservation: NSKeyValueObservation? + #if canImport(UIKit) + private weak var refreshControl: UIRefreshControl? + #endif + + init(navState: WebNavState, openURL: OpenURLAction, config: WebShellConfig) { + self.navState = navState + self.openURL = openURL + self.config = config + super.init() + } + + deinit { + progressObservation?.invalidate() + loadingObservation?.invalidate() + canGoBackObservation?.invalidate() + urlObservation?.invalidate() + } + + func observe(webView: WKWebView) { + progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, change in + guard let value = change.newValue else { return } + Task { @MainActor in + self?.navState.estimatedProgress = value + } + } + loadingObservation = webView.observe(\.isLoading, options: [.new]) { [weak self] _, change in + guard let value = change.newValue else { return } + Task { @MainActor in + self?.navState.isLoading = value + } + } + canGoBackObservation = webView.observe(\.canGoBack, options: [.new]) { [weak self] _, change in + guard let value = change.newValue else { return } + Task { @MainActor in + self?.navState.canGoBack = value + } + } + urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in + let value = change.newValue ?? nil + Task { @MainActor in + self?.navState.currentURL = value + } + } + } + + func load(_ url: URL, into webView: WKWebView) { + log.info("WebShell load: \(url.absoluteString, privacy: .public)") + navState.lastError = nil + let request = URLRequest(url: url) + webView.load(request) + } + + #if canImport(UIKit) + func attachRefresh(_ control: UIRefreshControl, webView: WKWebView) { + refreshControl = control + control.addAction( + UIAction { [weak self, weak webView] _ in + webView?.reload() + Task { @MainActor in + self?.refreshControl?.endRefreshing() + } + }, + for: .valueChanged + ) + } + #endif + + // MARK: - WKNavigationDelegate + + public func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction + ) async -> WKNavigationActionPolicy { + guard let url = navigationAction.request.url else { return .allow } + if let host = url.host, config.isAllowed(host: host) { + return .allow + } + if url.scheme == "http" || url.scheme == "https" { + log.info("WebShell → openURL extern: \(url.absoluteString, privacy: .public)") + openURL(url) + return .cancel + } + return .cancel + } + + public func webView(_: WKWebView, didFail _: WKNavigation, withError error: Error) { + log.warning("didFail: \(String(describing: error), privacy: .public)") + navState.lastError = (error as NSError).localizedDescription + } + + public func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError error: Error) { + log.warning("didFailProvisional: \(String(describing: error), privacy: .public)") + navState.lastError = (error as NSError).localizedDescription + } + + public func webView(_: WKWebView, didFinish _: WKNavigation) { + navState.lastError = nil + } + + // MARK: - WKUIDelegate + + public func webView( + _ webView: WKWebView, + createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures _: WKWindowFeatures + ) -> WKWebView? { + // `target=_blank`-Links: kein neues Fenster, im aktuellen WebView + // laden bzw. extern öffnen. + if let url = navigationAction.request.url { + if let host = url.host, config.isAllowed(host: host) { + webView.load(navigationAction.request) + } else { + openURL(url) + } + } + return nil + } +} diff --git a/Sources/ManaWebShell/WebShellScripts.swift b/Sources/ManaWebShell/WebShellScripts.swift new file mode 100644 index 0000000..3291b89 --- /dev/null +++ b/Sources/ManaWebShell/WebShellScripts.swift @@ -0,0 +1,119 @@ +import Foundation +import WebKit + +/// Vor-gefertigte `WKUserScript`-Helfer für ``WebShellView``. Apps +/// pickern, was sie brauchen, und reichen das Ergebnis als +/// `config.userScripts` durch. +/// +/// `WKUserScript` ist MainActor-isolated; deshalb sind die Factory- +/// Methoden hier ebenfalls MainActor. Aufrufer leben sowieso auf Main +/// (SwiftUI `makeUIView`/`makeNSView` sind MainActor). +@MainActor +public enum WebShellScripts { + /// Erzwingt Dark-Color-Scheme im WebView, indem ein `` injiziert und `.dark` an + /// `` gehängt wird. Sinnvoll für Web-Apps, die nur Dark- + /// Styles haben (Seepuls) oder bei denen die App das Light/Dark + /// hart festlegt. + public static let preferDarkScheme: WKUserScript = .init( + source: """ + (function() { + var meta = document.querySelector('meta[name="color-scheme"]'); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('name', 'color-scheme'); + (document.head || document.documentElement).appendChild(meta); + } + meta.setAttribute('content', 'dark'); + var html = document.documentElement; + if (html) html.classList.add('dark'); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + + /// Synct den System-Dark-Mode in den WebView via + /// `matchMedia('(prefers-color-scheme: dark)')`. Setzt eine + /// `.dark`-Klasse auf `` und optional einen `localStorage`- + /// Key, an dem das Web-Theme hängt. Listener für Live-Switch + /// während die Page offen ist. + /// + /// - Parameter localStorageKey: Key, an dem das Web seinen Theme- + /// State liest. `nil` falls Web nur auf `.dark` reagiert. + public static func syncDarkMode(localStorageKey: String? = nil) -> WKUserScript { + let setStorage: String + if let key = localStorageKey { + let escaped = key.replacingOccurrences(of: "'", with: "\\'") + setStorage = """ + try { + if (isDark) localStorage.setItem('\(escaped)', 'dark'); + else localStorage.removeItem('\(escaped)'); + } catch (e) {} + """ + } else { + setStorage = "" + } + let source = """ + (function() { + function apply(isDark) { + \(setStorage) + var html = document.documentElement; + if (!html) return; + if (isDark) html.classList.add('dark'); + else html.classList.remove('dark'); + } + var mq = window.matchMedia('(prefers-color-scheme: dark)'); + apply(mq.matches); + if (mq.addEventListener) { + mq.addEventListener('change', function(e) { apply(e.matches); }); + } + })(); + """ + return WKUserScript( + source: source, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + } + + /// Versteckt eine Top-Nav-Komponente per CSS, damit eine native + /// TabBar nicht doppelt rendert. Mehrere Selektoren werden + /// gestapelt (mit `,`-Group), damit ein Markup-Refactor in + /// Web-Land das Hide nicht still bricht. + /// + /// Konvention für Selektor-Kaskaden: + /// 1. `nav[data-app-nav]` / `header[data-app-nav]` — explizites + /// Attribut, falls Web es markieren will (greift sofort) + /// 2. strukturell (`body header:has(a.brand)` o.ä.) — heutige + /// Realität + /// 3. positionell (`body > nav:first-of-type`) — Fallback + /// + /// - Parameter selectors: CSS-Selektoren, die `display: none + /// !important` bekommen. Werden mit `,` gejoint. + /// - Parameter tagName: Wert für das `data-mana-webshell`- + /// Attribut auf dem Style-Tag (debugging, source inspection). + public static func hideElements( + selectors: [String], + tagName: String = "hide" + ) -> WKUserScript { + let joined = selectors.joined(separator: ",\n") + let escapedTag = tagName.replacingOccurrences(of: "'", with: "\\'") + let source = """ + (function() { + var css = `\(joined) { + display: none !important; + }`; + var style = document.createElement('style'); + style.setAttribute('data-mana-webshell', '\(escapedTag)'); + style.textContent = css; + (document.head || document.documentElement).appendChild(style); + })(); + """ + return WKUserScript( + source: source, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + } +} diff --git a/Sources/ManaWebShell/WebShellView.swift b/Sources/ManaWebShell/WebShellView.swift new file mode 100644 index 0000000..98cc341 --- /dev/null +++ b/Sources/ManaWebShell/WebShellView.swift @@ -0,0 +1,183 @@ +import SwiftUI +import WebKit + +/// SwiftUI-Hülle um `WKWebView`. Eine Instanz gehört üblicherweise zu +/// einem Tab/Screen und behält ihren Web-State (Scroll-Position, +/// Browser-History) während die View lebt. +/// +/// **Verhalten:** +/// - Lädt `target` beim ersten Auftauchen. +/// - Wechselt `target` während die View lebt → lädt neue URL (oder +/// reloaded, wenn `target.reloadToken` sich erhöht). +/// - Pull-to-Refresh über `UIRefreshControl` (iOS / iPadOS). +/// - Links auf nicht-gelisteten Hosts (siehe ``WebShellConfig/allowedHosts``) +/// und `target=_blank` öffnen im System-Browser via +/// `OpenURLAction`, nicht im WebView. +/// - Cookies werden über `WKWebsiteDataStore.default()` geteilt. +/// +/// **Theme-Hint:** +/// `config.backgroundColor` ist der Hintergrund hinter dem WKWebView +/// — verhindert weißen Flash bis zum first paint. Apps mit Dark-Theme +/// setzen das auf ihren Theme-Background. +public struct WebShellView: View { + let target: WebTarget + let config: WebShellConfig + + @State private var navState = WebNavState() + @Environment(\.openURL) private var openURL + + public init(target: WebTarget, config: WebShellConfig) { + self.target = target + self.config = config + } + + public var body: some View { + VStack(spacing: 0) { + if navState.isLoading { + ProgressView(value: navState.estimatedProgress) + .progressViewStyle(.linear) + .tint(config.progressTint) + .frame(height: 2) + } + #if canImport(UIKit) + WebViewRepresentable( + target: target, + navState: navState, + openURL: openURL, + config: config + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(config.backgroundColor) + #elseif canImport(AppKit) + MacWebViewRepresentable( + target: target, + navState: navState, + openURL: openURL, + config: config + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(config.backgroundColor) + #endif + if let error = navState.lastError { + errorBar(error) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorBar(_ message: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(config.errorIconColor) + Text(message) + .font(.caption) + .lineLimit(2) + .foregroundStyle(config.errorForegroundColor) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(config.errorBackgroundColor) + } +} + +#if canImport(UIKit) +import UIKit + +private struct WebViewRepresentable: UIViewRepresentable { + let target: WebTarget + let navState: WebNavState + let openURL: OpenURLAction + let config: WebShellConfig + + func makeCoordinator() -> WebShellCoordinator { + WebShellCoordinator(navState: navState, openURL: openURL, config: config) + } + + func makeUIView(context: Context) -> WKWebView { + let wkConfig = WKWebViewConfiguration() + wkConfig.websiteDataStore = .default() + wkConfig.applicationNameForUserAgent = config.userAgent + for script in config.userScripts { + wkConfig.userContentController.addUserScript(script) + } + let webView = WKWebView(frame: .zero, configuration: wkConfig) + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + // Ohne diese drei flackert WKWebView bis zum first paint weiß + // gegen das App-Theme — egal was der SwiftUI-Container als + // Background setzt. + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.refreshControl = makeRefreshControl( + webView: webView, + coordinator: context.coordinator + ) + context.coordinator.observe(webView: webView) + context.coordinator.load(target.url, into: webView) + context.coordinator.lastTarget = target + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + let coord = context.coordinator + if coord.lastTarget != target { + coord.load(target.url, into: webView) + coord.lastTarget = target + } + } + + private func makeRefreshControl( + webView: WKWebView, + coordinator: WebShellCoordinator + ) -> UIRefreshControl { + let refresh = UIRefreshControl() + coordinator.attachRefresh(refresh, webView: webView) + return refresh + } +} + +#elseif canImport(AppKit) +import AppKit + +private struct MacWebViewRepresentable: NSViewRepresentable { + let target: WebTarget + let navState: WebNavState + let openURL: OpenURLAction + let config: WebShellConfig + + func makeCoordinator() -> WebShellCoordinator { + WebShellCoordinator(navState: navState, openURL: openURL, config: config) + } + + func makeNSView(context: Context) -> WKWebView { + let wkConfig = WKWebViewConfiguration() + wkConfig.websiteDataStore = .default() + wkConfig.applicationNameForUserAgent = config.userAgent + for script in config.userScripts { + wkConfig.userContentController.addUserScript(script) + } + let webView = WKWebView(frame: .zero, configuration: wkConfig) + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + // macOS-Pendant zu UIView.isOpaque=false — sonst weißer Flash + // vor first paint. + webView.setValue(false, forKey: "drawsBackground") + context.coordinator.observe(webView: webView) + context.coordinator.load(target.url, into: webView) + context.coordinator.lastTarget = target + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + let coord = context.coordinator + if coord.lastTarget != target { + coord.load(target.url, into: webView) + coord.lastTarget = target + } + } +} +#endif diff --git a/Sources/ManaWebShell/WebTarget.swift b/Sources/ManaWebShell/WebTarget.swift new file mode 100644 index 0000000..682525d --- /dev/null +++ b/Sources/ManaWebShell/WebTarget.swift @@ -0,0 +1,14 @@ +import Foundation + +/// URL + monoton wachsende `reloadToken`. Ein neuer Token zwingt den +/// WebView, dieselbe URL nochmal zu laden — wird gebraucht wenn der +/// User auf einen Universal-Link tappt, der zur aktuellen URL führt. +public struct WebTarget: Equatable, Sendable { + public let url: URL + public let reloadToken: Int + + public init(url: URL, reloadToken: Int = 0) { + self.url = url + self.reloadToken = reloadToken + } +} diff --git a/Tests/ManaWebShellTests/WebShellConfigTests.swift b/Tests/ManaWebShellTests/WebShellConfigTests.swift new file mode 100644 index 0000000..46bddcb --- /dev/null +++ b/Tests/ManaWebShellTests/WebShellConfigTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import ManaWebShell + +@Suite("WebShellConfig — Host-Whitelist") +struct WebShellConfigTests { + private func config(_ hosts: [String]) -> WebShellConfig { + WebShellConfig(allowedHosts: hosts, userAgent: "TestNative/0.1") + } + + @Test("Exakter Host matched") + func exactMatch() { + let c = config(["seepuls.mana.how"]) + #expect(c.isAllowed(host: "seepuls.mana.how")) + #expect(!c.isAllowed(host: "other.mana.how")) + #expect(!c.isAllowed(host: "mana.how")) + #expect(!c.isAllowed(host: "evil.com")) + } + + @Test("Wildcard *.root matched Subdomain") + func wildcardSubdomain() { + let c = config(["*.mana.how"]) + #expect(c.isAllowed(host: "seepuls.mana.how")) + #expect(c.isAllowed(host: "auth.mana.how")) + #expect(c.isAllowed(host: "deep.nested.mana.how")) + } + + @Test("Wildcard *.root matched Root selbst") + func wildcardCoversRoot() { + let c = config(["*.mana.how"]) + #expect(c.isAllowed(host: "mana.how")) + } + + @Test("Wildcard matched nicht andere TLDs") + func wildcardScoped() { + let c = config(["*.mana.how"]) + #expect(!c.isAllowed(host: "mana.com")) + #expect(!c.isAllowed(host: "fake-mana.how")) + #expect(!c.isAllowed(host: "evil.com")) + } + + @Test("Mehrere Patterns kombinieren") + func mixedPatterns() { + let c = config(["zitare.com", "www.zitare.com", "*.mana.how"]) + #expect(c.isAllowed(host: "zitare.com")) + #expect(c.isAllowed(host: "www.zitare.com")) + #expect(c.isAllowed(host: "auth.mana.how")) + #expect(c.isAllowed(host: "mana.how")) + #expect(!c.isAllowed(host: "other.zitare.com")) + } + + @Test("Leere Whitelist verbietet alles") + func emptyDenies() { + let c = config([]) + #expect(!c.isAllowed(host: "anything.com")) + } +}