···
1
+
import { css, html, LitElement } from "lit";
2
+
import { customElement, property, state } from "lit/decorators.js";
4
+
export interface PasswordStrengthResult {
13
+
@customElement("password-strength")
14
+
export class PasswordStrength extends LitElement {
15
+
@property({ type: String }) password = "";
16
+
@state() private isChecking = false;
17
+
@state() private isPwned = false;
18
+
@state() private hasChecked = false;
20
+
static override styles = css`
26
+
.strength-container {
28
+
flex-direction: column;
32
+
.strength-bar-container {
35
+
background: var(--secondary);
42
+
transition: width 0.3s ease, background-color 0.3s ease;
48
+
justify-content: space-between;
49
+
align-items: center;
58
+
color: var(--accent);
64
+
color: var(--paynes-gray);
69
+
private calculateStrength(): PasswordStrengthResult {
70
+
const password = this.password;
77
+
color: "transparent",
79
+
isChecking: this.isChecking,
80
+
isPwned: this.isPwned,
84
+
// Length bonus (up to 40 points)
85
+
score += Math.min(password.length * 3, 40);
87
+
// Character variety (up to 50 points)
88
+
if (/[a-z]/.test(password)) score += 10;
89
+
if (/[A-Z]/.test(password)) score += 10;
90
+
if (/[0-9]/.test(password)) score += 10;
91
+
if (/[^a-zA-Z0-9]/.test(password)) score += 15;
93
+
// Character diversity bonus (up to 10 points)
94
+
const uniqueChars = new Set(password).size;
95
+
score += Math.min(uniqueChars, 10);
97
+
score = Math.min(score, 100);
101
+
let isValid = false;
105
+
color = "#ef8354"; // accent/coral
107
+
} else if (score < 60) {
109
+
color = "#f4a261"; // orange
110
+
isValid = password.length >= 12;
111
+
} else if (score < 80) {
113
+
color = "#2a9d8f"; // teal
116
+
label = "Excellent";
117
+
color = "#264653"; // dark teal
121
+
// Invalid if pwned
122
+
if (this.isPwned && this.hasChecked) {
131
+
isChecking: this.isChecking,
132
+
isPwned: this.isPwned,
136
+
async checkHIBP(password: string) {
137
+
if (!password || password.length < 8) {
138
+
this.hasChecked = true;
142
+
this.isChecking = true;
143
+
this.isPwned = false;
146
+
// Hash password with SHA-1
147
+
const encoder = new TextEncoder();
148
+
const data = encoder.encode(password);
149
+
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
150
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
151
+
const hashHex = hashArray
152
+
.map((b) => b.toString(16).padStart(2, "0"))
156
+
// Send first 5 chars to HIBP API (k-anonymity)
157
+
const prefix = hashHex.substring(0, 5);
158
+
const suffix = hashHex.substring(5);
160
+
const response = await fetch(
161
+
`https://api.pwnedpasswords.com/range/${prefix}`,
165
+
const text = await response.text();
166
+
const hashes = text.split("\n");
168
+
// Check if our hash suffix appears in the response
169
+
this.isPwned = hashes.some((line) => line.startsWith(suffix));
172
+
console.error("HIBP check failed:", error);
173
+
// Don't block on error
175
+
this.isChecking = false;
176
+
this.hasChecked = true;
180
+
// Public method to get current state
181
+
getStrengthResult(): PasswordStrengthResult {
182
+
return this.calculateStrength();
185
+
override render() {
186
+
const strength = this.calculateStrength();
188
+
if (!this.password) {
193
+
<div class="strength-container">
194
+
<div class="strength-bar-container">
196
+
class="strength-bar"
197
+
style="width: ${strength.score}%; background-color: ${strength.color};"
200
+
<div class="strength-info">
201
+
<span class="strength-label">${strength.label}</span>
204
+
? html`<span class="checking">Checking security...</span>`
206
+
? html`<span class="pwned-warning"
207
+
>⚠️ This password has been exposed in databreaches</span
216
+
override updated(changedProperties: Map<string, unknown>) {
217
+
if (changedProperties.has("password")) {
218
+
// Reset checking state when password changes
219
+
this.hasChecked = false;
220
+
this.isPwned = false;
222
+
// Dispatch event so parent knows to disable/enable submit
223
+
this.dispatchEvent(
224
+
new CustomEvent("strength-change", {
225
+
detail: this.calculateStrength(),