馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, property, state } from "lit/decorators.js"; 3 4export interface PasswordStrengthResult { 5 score: number; 6 label: string; 7 color: string; 8 isValid: boolean; 9 isChecking: boolean; 10 isPwned: boolean; 11} 12 13@customElement("password-strength") 14export class PasswordStrength extends LitElement { 15 @property({ type: String }) password = ""; 16 @state() private isChecking = false; 17 @state() private isPwned = false; 18 @state() private hasChecked = false; 19 20 static override styles = css` 21 :host { 22 display: block; 23 margin-top: 0.5rem; 24 } 25 26 .strength-container { 27 display: flex; 28 flex-direction: column; 29 gap: 0.5rem; 30 } 31 32 .strength-bar-container { 33 width: 100%; 34 height: 0.5rem; 35 background: var(--secondary); 36 border-radius: 4px; 37 overflow: hidden; 38 } 39 40 .strength-bar { 41 height: 100%; 42 transition: width 0.3s ease, background-color 0.3s ease; 43 border-radius: 4px; 44 } 45 46 .strength-info { 47 display: flex; 48 justify-content: space-between; 49 align-items: center; 50 font-size: 0.75rem; 51 } 52 53 .strength-label { 54 font-weight: 500; 55 } 56 57 .pwned-warning { 58 color: var(--accent); 59 font-size: 0.75rem; 60 font-weight: 500; 61 } 62 63 .checking { 64 color: var(--paynes-gray); 65 font-size: 0.75rem; 66 } 67 `; 68 69 private calculateStrength(): PasswordStrengthResult { 70 const password = this.password; 71 let score = 0; 72 73 if (!password) { 74 return { 75 score: 0, 76 label: "", 77 color: "transparent", 78 isValid: false, 79 isChecking: this.isChecking, 80 isPwned: this.isPwned, 81 }; 82 } 83 84 // Length bonus (up to 40 points) 85 score += Math.min(password.length * 3, 40); 86 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; 92 93 // Character diversity bonus (up to 10 points) 94 const uniqueChars = new Set(password).size; 95 score += Math.min(uniqueChars, 10); 96 97 score = Math.min(score, 100); 98 99 let label = ""; 100 let color = ""; 101 let isValid = false; 102 103 if (score < 30) { 104 label = "Weak"; 105 color = "#ef8354"; // accent/coral 106 isValid = false; 107 } else if (score < 60) { 108 label = "Fair"; 109 color = "#f4a261"; // orange 110 isValid = password.length >= 12; 111 } else if (score < 80) { 112 label = "Strong"; 113 color = "#2a9d8f"; // teal 114 isValid = true; 115 } else { 116 label = "Excellent"; 117 color = "#264653"; // dark teal 118 isValid = true; 119 } 120 121 // Invalid if pwned 122 if (this.isPwned && this.hasChecked) { 123 isValid = false; 124 } 125 126 return { 127 score, 128 label, 129 color, 130 isValid, 131 isChecking: this.isChecking, 132 isPwned: this.isPwned, 133 }; 134 } 135 136 async checkHIBP(password: string) { 137 if (!password || password.length < 8) { 138 this.hasChecked = true; 139 return; 140 } 141 142 // Skip if crypto.subtle is not available (non-HTTPS) 143 if (!crypto.subtle) { 144 this.hasChecked = true; 145 return; 146 } 147 148 this.isChecking = true; 149 this.isPwned = false; 150 151 try { 152 // Hash password with SHA-1 153 const encoder = new TextEncoder(); 154 const data = encoder.encode(password); 155 const hashBuffer = await crypto.subtle.digest("SHA-1", data); 156 const hashArray = Array.from(new Uint8Array(hashBuffer)); 157 const hashHex = hashArray 158 .map((b) => b.toString(16).padStart(2, "0")) 159 .join("") 160 .toUpperCase(); 161 162 // Send first 5 chars to HIBP API (k-anonymity) 163 const prefix = hashHex.substring(0, 5); 164 const suffix = hashHex.substring(5); 165 166 const response = await fetch( 167 `https://api.pwnedpasswords.com/range/${prefix}`, 168 ); 169 170 if (response.ok) { 171 const text = await response.text(); 172 const hashes = text.split("\n"); 173 174 // Check if our hash suffix appears in the response 175 this.isPwned = hashes.some((line) => line.startsWith(suffix)); 176 } 177 } catch (error) { 178 console.error("HIBP check failed:", error); 179 // Don't block on error 180 } finally { 181 this.isChecking = false; 182 this.hasChecked = true; 183 } 184 } 185 186 // Public method to get current state 187 getStrengthResult(): PasswordStrengthResult { 188 return this.calculateStrength(); 189 } 190 191 override render() { 192 const strength = this.calculateStrength(); 193 194 if (!this.password) { 195 return html``; 196 } 197 198 return html` 199 <div class="strength-container"> 200 <div class="strength-bar-container"> 201 <div 202 class="strength-bar" 203 style="width: ${strength.score}%; background-color: ${strength.color};" 204 ></div> 205 </div> 206 <div class="strength-info"> 207 <span class="strength-label">${strength.label}</span> 208 ${ 209 this.isChecking 210 ? html`<span class="checking">Checking security...</span>` 211 : this.isPwned 212 ? html`<span class="pwned-warning" 213 >鈿狅笍 This password has been exposed in databreaches</span 214 >` 215 : "" 216 } 217 </div> 218 </div> 219 `; 220 } 221 222 override updated(changedProperties: Map<string, unknown>) { 223 if (changedProperties.has("password")) { 224 // Reset checking state when password changes 225 this.hasChecked = false; 226 this.isPwned = false; 227 228 // Dispatch event so parent knows to disable/enable submit 229 this.dispatchEvent( 230 new CustomEvent("strength-change", { 231 detail: this.calculateStrength(), 232 bubbles: true, 233 composed: true, 234 }), 235 ); 236 } 237 } 238}