🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add a password strength checker

+263 -1
+31 -1
src/components/auth.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import { hashPasswordClient } from "../lib/client-auth"; 4 + import type { PasswordStrength } from "./password-strength"; 5 + import "./password-strength"; 6 + import type { PasswordStrengthResult } from "./password-strength"; 4 7 5 8 interface User { 6 9 email: string; ··· 19 22 @state() error = ""; 20 23 @state() isSubmitting = false; 21 24 @state() needsRegistration = false; 25 + @state() passwordStrength: PasswordStrengthResult | null = null; 22 26 23 27 static override styles = css` 24 28 :host { ··· 379 383 this.password = (e.target as HTMLInputElement).value; 380 384 } 381 385 386 + private handlePasswordBlur() { 387 + if (!this.needsRegistration) return; 388 + 389 + const strengthComponent = this.shadowRoot?.querySelector( 390 + "password-strength", 391 + ) as PasswordStrength | null; 392 + if (strengthComponent && this.password) { 393 + strengthComponent.checkHIBP(this.password); 394 + } 395 + } 396 + 397 + private handleStrengthChange(e: CustomEvent<PasswordStrengthResult>) { 398 + this.passwordStrength = e.detail; 399 + } 400 + 382 401 override render() { 383 402 if (this.loading) { 384 403 return html`<div class="loading">Loading...</div>`; ··· 479 498 placeholder="*************" 480 499 .value=${this.password} 481 500 @input=${this.handlePasswordInput} 501 + @blur=${this.handlePasswordBlur} 482 502 required 483 503 ?disabled=${this.isSubmitting} 484 504 /> 505 + ${ 506 + this.needsRegistration 507 + ? html`<password-strength 508 + .password=${this.password} 509 + @strength-change=${this.handleStrengthChange} 510 + ></password-strength>` 511 + : "" 512 + } 485 513 </div> 486 514 487 515 ${ ··· 494 522 <button 495 523 type="submit" 496 524 class="btn-primary" 497 - ?disabled=${this.isSubmitting} 525 + ?disabled=${this.isSubmitting || 526 + (this.passwordStrength?.isChecking ?? false) || 527 + (this.needsRegistration && !(this.passwordStrength?.isValid ?? false))} 498 528 > 499 529 ${ 500 530 this.isSubmitting
+232
src/components/password-strength.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + export 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") 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; 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 + }