mana-swift-ui/Sources/ManaAuthUI/Components/ManaPasswordStrength.swift
Till JS 5d7d486323 feat(ManaAuthUI): Passwortstärke-Anzeige in Sign-Up + Reset
ManaPasswordStrength (reine Heuristik: Laenge + Zeichenklassen, keine
externe Lib) + ManaPasswordStrengthView (segmentierte Anzeige, blendet
bei leerem Feld aus, .weak via brand.error). Eingebunden unter den
newPassword-Feldern in ManaSignUpView und ManaResetPasswordView.
7 Unit-Tests gegen die Bewertungslogik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:16:50 +02:00

98 lines
3.6 KiB
Swift

import SwiftUI
/// Geschätzte Passwortstärke für Sign-Up/Reset-Felder. Heuristik aus
/// Länge und Zeichenklassen — bewusst simpel, ohne externe Bibliothek
/// (Invariante 1: pure SwiftUI/Foundation). Kein Ersatz für eine
/// serverseitige Policy, sondern visuelle Soforthilfe für den Menschen
/// beim Wählen eines guten Passworts.
public enum ManaPasswordStrength: Int, Sendable, CaseIterable {
case weak = 1
case fair = 2
case good = 3
case strong = 4
/// Deutsches Label für die Anzeige.
public var label: String {
switch self {
case .weak: "schwach"
case .fair: "mittel"
case .good: "gut"
case .strong: "stark"
}
}
/// Bewertet ein Passwort. `nil` für leere Eingabe (dann nichts
/// anzeigen). Unterhalb `minLength` (Default 8, die
/// mana-auth-Mindestlänge) immer `.weak`, egal wie komplex —
/// ein zu kurzes Passwort soll nie „gut" wirken.
///
/// Punkte: Mindestlänge (+1), ≥12 Zeichen (+1), Groß- **und**
/// Kleinbuchstaben (+1), Ziffer (+1), Sonderzeichen (+1).
public static func evaluate(
_ password: String,
minLength: Int = 8
) -> ManaPasswordStrength? {
guard !password.isEmpty else { return nil }
guard password.count >= minLength else { return .weak }
var score = 1 // Mindestlänge erreicht
if password.count >= 12 { score += 1 }
let hasLower = password.contains { $0.isLowercase }
let hasUpper = password.contains { $0.isUppercase }
let hasDigit = password.contains(where: \.isNumber)
let hasSymbol = password.contains { !$0.isLetter && !$0.isNumber && !$0.isWhitespace }
if hasLower, hasUpper { score += 1 }
if hasDigit { score += 1 }
if hasSymbol { score += 1 }
switch score {
case ...1: return .weak
case 2: return .fair
case 3, 4: return .good
default: return .strong
}
}
}
/// Segmentierte Stärke-Anzeige für unter ein Passwortfeld. Blendet
/// sich aus, solange das Feld leer ist. Nutzt nur Brand-Semantik:
/// `.weak` wird per `brand.error` rot markiert, sonst `brand.primary`.
public struct ManaPasswordStrengthView: View {
@Environment(\.manaBrand) private var brand
private let password: String
private let minLength: Int
/// - Parameters:
/// - password: aktueller Feld-Inhalt.
/// - minLength: Mindestlänge unterhalb derer immer `.weak` gilt
/// (Default 8 — passend zu mana-auth).
public init(password: String, minLength: Int = 8) {
self.password = password
self.minLength = minLength
}
public var body: some View {
if let strength = ManaPasswordStrength.evaluate(password, minLength: minLength) {
let tint = strength == .weak ? brand.error : brand.primary
VStack(spacing: 6) {
HStack(spacing: 4) {
ForEach(ManaPasswordStrength.allCases, id: \.rawValue) { step in
Capsule()
.fill(step.rawValue <= strength.rawValue ? tint : brand.border)
.frame(height: 4)
}
}
Text("Passwortstärke: \(strength.label)")
.font(.caption)
.foregroundStyle(strength == .weak ? brand.error : brand.mutedForeground)
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Passwortstärke: \(strength.label)")
.animation(.easeInOut(duration: 0.2), value: strength)
}
}
}