馃 distributed transcription service thistle.dunkirk.sh
at v0.1.0 5.2 kB view raw
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 this.isChecking = true; 143 this.isPwned = false; 144 145 try { 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")) 153 .join("") 154 .toUpperCase(); 155 156 // Send first 5 chars to HIBP API (k-anonymity) 157 const prefix = hashHex.substring(0, 5); 158 const suffix = hashHex.substring(5); 159 160 const response = await fetch( 161 `https://api.pwnedpasswords.com/range/${prefix}`, 162 ); 163 164 if (response.ok) { 165 const text = await response.text(); 166 const hashes = text.split("\n"); 167 168 // Check if our hash suffix appears in the response 169 this.isPwned = hashes.some((line) => line.startsWith(suffix)); 170 } 171 } catch (error) { 172 console.error("HIBP check failed:", error); 173 // Don't block on error 174 } finally { 175 this.isChecking = false; 176 this.hasChecked = true; 177 } 178 } 179 180 // Public method to get current state 181 getStrengthResult(): PasswordStrengthResult { 182 return this.calculateStrength(); 183 } 184 185 override render() { 186 const strength = this.calculateStrength(); 187 188 if (!this.password) { 189 return html``; 190 } 191 192 return html` 193 <div class="strength-container"> 194 <div class="strength-bar-container"> 195 <div 196 class="strength-bar" 197 style="width: ${strength.score}%; background-color: ${strength.color};" 198 ></div> 199 </div> 200 <div class="strength-info"> 201 <span class="strength-label">${strength.label}</span> 202 ${ 203 this.isChecking 204 ? html`<span class="checking">Checking security...</span>` 205 : this.isPwned 206 ? html`<span class="pwned-warning" 207 >鈿狅笍 This password has been exposed in databreaches</span 208 >` 209 : "" 210 } 211 </div> 212 </div> 213 `; 214 } 215 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; 221 222 // Dispatch event so parent knows to disable/enable submit 223 this.dispatchEvent( 224 new CustomEvent("strength-change", { 225 detail: this.calculateStrength(), 226 bubbles: true, 227 composed: true, 228 }), 229 ); 230 } 231 } 232}