馃 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 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}