···
+
import { css, html, LitElement } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
export interface PasswordStrengthResult {
+
@customElement("password-strength")
+
export class PasswordStrength extends LitElement {
+
@property({ type: String }) password = "";
+
@state() private isChecking = false;
+
@state() private isPwned = false;
+
@state() private hasChecked = false;
+
static override styles = css`
+
flex-direction: column;
+
.strength-bar-container {
+
background: var(--secondary);
+
transition: width 0.3s ease, background-color 0.3s ease;
+
justify-content: space-between;
+
color: var(--paynes-gray);
+
private calculateStrength(): PasswordStrengthResult {
+
const password = this.password;
+
isChecking: this.isChecking,
+
// Length bonus (up to 40 points)
+
score += Math.min(password.length * 3, 40);
+
// Character variety (up to 50 points)
+
if (/[a-z]/.test(password)) score += 10;
+
if (/[A-Z]/.test(password)) score += 10;
+
if (/[0-9]/.test(password)) score += 10;
+
if (/[^a-zA-Z0-9]/.test(password)) score += 15;
+
// Character diversity bonus (up to 10 points)
+
const uniqueChars = new Set(password).size;
+
score += Math.min(uniqueChars, 10);
+
score = Math.min(score, 100);
+
color = "#ef8354"; // accent/coral
+
} else if (score < 60) {
+
color = "#f4a261"; // orange
+
isValid = password.length >= 12;
+
} else if (score < 80) {
+
color = "#2a9d8f"; // teal
+
color = "#264653"; // dark teal
+
if (this.isPwned && this.hasChecked) {
+
isChecking: this.isChecking,
+
async checkHIBP(password: string) {
+
if (!password || password.length < 8) {
+
this.hasChecked = true;
+
this.isChecking = true;
+
// Hash password with SHA-1
+
const encoder = new TextEncoder();
+
const data = encoder.encode(password);
+
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
+
const hashHex = hashArray
+
.map((b) => b.toString(16).padStart(2, "0"))
+
// Send first 5 chars to HIBP API (k-anonymity)
+
const prefix = hashHex.substring(0, 5);
+
const suffix = hashHex.substring(5);
+
const response = await fetch(
+
`https://api.pwnedpasswords.com/range/${prefix}`,
+
const text = await response.text();
+
const hashes = text.split("\n");
+
// Check if our hash suffix appears in the response
+
this.isPwned = hashes.some((line) => line.startsWith(suffix));
+
console.error("HIBP check failed:", error);
+
// Don't block on error
+
this.isChecking = false;
+
this.hasChecked = true;
+
// Public method to get current state
+
getStrengthResult(): PasswordStrengthResult {
+
return this.calculateStrength();
+
const strength = this.calculateStrength();
+
<div class="strength-container">
+
<div class="strength-bar-container">
+
style="width: ${strength.score}%; background-color: ${strength.color};"
+
<div class="strength-info">
+
<span class="strength-label">${strength.label}</span>
+
? html`<span class="checking">Checking security...</span>`
+
? html`<span class="pwned-warning"
+
>⚠️ This password has been exposed in databreaches</span
+
override updated(changedProperties: Map<string, unknown>) {
+
if (changedProperties.has("password")) {
+
// Reset checking state when password changes
+
this.hasChecked = false;
+
// Dispatch event so parent knows to disable/enable submit
+
new CustomEvent("strength-change", {
+
detail: this.calculateStrength(),