🪻 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, property, state } from "lit/decorators.js"; 3import { hashPasswordClient } from "../lib/client-auth"; 4 5@customElement("reset-password-form") 6export class ResetPasswordForm extends LitElement { 7 @property({ type: String }) token: string | null = null; 8 @state() private email: string | null = null; 9 @state() private password = ""; 10 @state() private confirmPassword = ""; 11 @state() private error = ""; 12 @state() private isSubmitting = false; 13 @state() private isSuccess = false; 14 @state() private isLoadingEmail = false; 15 16 static override styles = css` 17 :host { 18 display: block; 19 } 20 21 .reset-card { 22 background: var(--background); 23 border: 2px solid var(--secondary); 24 border-radius: 12px; 25 padding: 2.5rem; 26 max-width: 25rem; 27 width: 100%; 28 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 29 } 30 31 .reset-title { 32 margin-top: 0; 33 margin-bottom: 2rem; 34 color: var(--text); 35 text-align: center; 36 font-size: 1.75rem; 37 } 38 39 .form-group { 40 margin-bottom: 1.5rem; 41 } 42 43 label { 44 display: block; 45 margin-bottom: 0.25rem; 46 font-weight: 500; 47 color: var(--text); 48 font-size: 0.875rem; 49 } 50 51 input { 52 width: 100%; 53 padding: 0.75rem; 54 border: 2px solid var(--secondary); 55 border-radius: 6px; 56 font-size: 1rem; 57 font-family: inherit; 58 background: var(--background); 59 color: var(--text); 60 transition: all 0.2s; 61 box-sizing: border-box; 62 } 63 64 input::placeholder { 65 color: var(--secondary); 66 opacity: 1; 67 } 68 69 input:focus { 70 outline: none; 71 border-color: var(--primary); 72 } 73 74 .error-banner { 75 background: #fecaca; 76 border: 2px solid rgba(220, 38, 38, 0.8); 77 border-radius: 6px; 78 padding: 1rem; 79 margin-bottom: 1rem; 80 color: #dc2626; 81 font-weight: 500; 82 } 83 84 .btn-primary { 85 width: 100%; 86 padding: 0.75rem 1.5rem; 87 border: 2px solid var(--primary); 88 border-radius: 6px; 89 font-size: 1rem; 90 font-weight: 500; 91 cursor: pointer; 92 transition: all 0.2s; 93 font-family: inherit; 94 background: var(--primary); 95 color: white; 96 margin-top: 0.5rem; 97 } 98 99 .btn-primary:hover:not(:disabled) { 100 background: transparent; 101 color: var(--primary); 102 } 103 104 .btn-primary:disabled { 105 opacity: 0.6; 106 cursor: not-allowed; 107 } 108 109 .back-link { 110 display: block; 111 text-align: center; 112 margin-top: 1.5rem; 113 color: var(--primary); 114 text-decoration: none; 115 font-weight: 500; 116 font-size: 0.875rem; 117 transition: all 0.2s; 118 } 119 120 .back-link:hover { 121 color: var(--accent); 122 } 123 124 .success-message { 125 text-align: center; 126 } 127 128 .success-icon { 129 font-size: 3rem; 130 margin-bottom: 1rem; 131 } 132 133 .success-text { 134 color: var(--primary); 135 font-size: 1.25rem; 136 font-weight: 500; 137 margin-bottom: 1.5rem; 138 } 139 140 .success-link { 141 display: inline-block; 142 padding: 0.75rem 1.5rem; 143 background: var(--accent); 144 color: white; 145 text-decoration: none; 146 border-radius: 6px; 147 font-weight: 500; 148 transition: all 0.2s; 149 } 150 151 .success-link:hover { 152 background: var(--primary); 153 } 154 `; 155 156 override async updated(changedProperties: Map<string, unknown>) { 157 super.updated(changedProperties); 158 159 // When token property changes and we don't have email yet, load it 160 if ( 161 changedProperties.has("token") && 162 this.token && 163 !this.email && 164 !this.isLoadingEmail 165 ) { 166 await this.loadEmail(); 167 } 168 } 169 170 private async loadEmail() { 171 this.isLoadingEmail = true; 172 this.error = ""; 173 174 try { 175 const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`; 176 const response = await fetch(url); 177 const data = await response.json(); 178 179 if (!response.ok) { 180 throw new Error(data.error || "Invalid or expired reset token"); 181 } 182 183 this.email = data.email; 184 } catch (err) { 185 this.error = 186 err instanceof Error ? err.message : "Failed to verify reset token"; 187 } finally { 188 this.isLoadingEmail = false; 189 } 190 } 191 192 override render() { 193 if (!this.token) { 194 return html` 195 <div class="reset-card"> 196 <h1 class="reset-title">Reset Password</h1> 197 <div class="error-banner">Invalid or missing reset token</div> 198 <a href="/" class="back-link">Back to home</a> 199 </div> 200 `; 201 } 202 203 if (this.isLoadingEmail) { 204 return html` 205 <div class="reset-card"> 206 <h1 class="reset-title">Reset Password</h1> 207 <p style="text-align: center; color: var(--text);">Verifying reset token...</p> 208 </div> 209 `; 210 } 211 212 if (this.error && !this.email) { 213 return html` 214 <div class="reset-card"> 215 <h1 class="reset-title">Reset Password</h1> 216 <div class="error-banner">${this.error}</div> 217 <a href="/" class="back-link">Back to home</a> 218 </div> 219 `; 220 } 221 222 if (this.isSuccess) { 223 return html` 224 <div class="reset-card"> 225 <div class="success-message"> 226 <div class="success-icon">✓</div> 227 <div class="success-text">Password reset successfully!</div> 228 <a href="/" class="success-link">Go to home</a> 229 </div> 230 </div> 231 `; 232 } 233 234 return html` 235 <div class="reset-card"> 236 <h1 class="reset-title">Reset Password</h1> 237 238 <form @submit=${this.handleSubmit}> 239 ${ 240 this.error 241 ? html`<div class="error-banner">${this.error}</div>` 242 : "" 243 } 244 245 <div class="form-group"> 246 <label for="password">New Password</label> 247 <input 248 type="password" 249 id="password" 250 .value=${this.password} 251 @input=${(e: Event) => { 252 this.password = (e.target as HTMLInputElement).value; 253 }} 254 required 255 minlength="8" 256 placeholder="Enter new password (min 8 characters)" 257 > 258 </div> 259 260 <div class="form-group"> 261 <label for="confirm-password">Confirm Password</label> 262 <input 263 type="password" 264 id="confirm-password" 265 .value=${this.confirmPassword} 266 @input=${(e: Event) => { 267 this.confirmPassword = (e.target as HTMLInputElement).value; 268 }} 269 required 270 minlength="8" 271 placeholder="Confirm new password" 272 > 273 </div> 274 275 <button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}> 276 ${this.isSubmitting ? "Resetting..." : "Reset Password"} 277 </button> 278 </form> 279 280 <a href="/" class="back-link">Back to home</a> 281 </div> 282 `; 283 } 284 285 private async handleSubmit(e: Event) { 286 e.preventDefault(); 287 this.error = ""; 288 289 // Validate passwords match 290 if (this.password !== this.confirmPassword) { 291 this.error = "Passwords do not match"; 292 return; 293 } 294 295 // Validate password length 296 if (this.password.length < 8) { 297 this.error = "Password must be at least 8 characters"; 298 return; 299 } 300 301 this.isSubmitting = true; 302 303 try { 304 if (!this.email) { 305 throw new Error("Email not loaded"); 306 } 307 308 // Hash password client-side with user's email 309 const hashedPassword = await hashPasswordClient( 310 this.password, 311 this.email, 312 ); 313 314 const response = await fetch("/api/auth/reset-password", { 315 method: "POST", 316 headers: { "Content-Type": "application/json" }, 317 body: JSON.stringify({ token: this.token, password: hashedPassword }), 318 }); 319 320 const data = await response.json(); 321 322 if (!response.ok) { 323 throw new Error(data.error || "Failed to reset password"); 324 } 325 326 // Show success message 327 this.isSuccess = true; 328 } catch (err) { 329 this.error = 330 err instanceof Error ? err.message : "Failed to reset password"; 331 } finally { 332 this.isSubmitting = false; 333 } 334 } 335}