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