🪻 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 (changedProperties.has('token') && this.token && !this.email && !this.isLoadingEmail) { 161 await this.loadEmail(); 162 } 163 } 164 165 private async loadEmail() { 166 this.isLoadingEmail = true; 167 this.error = ""; 168 169 try { 170 const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`; 171 const response = await fetch(url); 172 const data = await response.json(); 173 174 if (!response.ok) { 175 throw new Error(data.error || "Invalid or expired reset token"); 176 } 177 178 this.email = data.email; 179 } catch (err) { 180 this.error = err instanceof Error ? err.message : "Failed to verify reset token"; 181 } finally { 182 this.isLoadingEmail = false; 183 } 184 } 185 186 override render() { 187 if (!this.token) { 188 return html` 189 <div class="reset-card"> 190 <h1 class="reset-title">Reset Password</h1> 191 <div class="error-banner">Invalid or missing reset token</div> 192 <a href="/" class="back-link">Back to home</a> 193 </div> 194 `; 195 } 196 197 if (this.isLoadingEmail) { 198 return html` 199 <div class="reset-card"> 200 <h1 class="reset-title">Reset Password</h1> 201 <p style="text-align: center; color: var(--text);">Verifying reset token...</p> 202 </div> 203 `; 204 } 205 206 if (this.error && !this.email) { 207 return html` 208 <div class="reset-card"> 209 <h1 class="reset-title">Reset Password</h1> 210 <div class="error-banner">${this.error}</div> 211 <a href="/" class="back-link">Back to home</a> 212 </div> 213 `; 214 } 215 216 if (this.isSuccess) { 217 return html` 218 <div class="reset-card"> 219 <div class="success-message"> 220 <div class="success-icon">✓</div> 221 <div class="success-text">Password reset successfully!</div> 222 <a href="/" class="success-link">Go to home</a> 223 </div> 224 </div> 225 `; 226 } 227 228 return html` 229 <div class="reset-card"> 230 <h1 class="reset-title">Reset Password</h1> 231 232 <form @submit=${this.handleSubmit}> 233 ${this.error 234 ? html`<div class="error-banner">${this.error}</div>` 235 : ""} 236 237 <div class="form-group"> 238 <label for="password">New Password</label> 239 <input 240 type="password" 241 id="password" 242 .value=${this.password} 243 @input=${(e: Event) => { 244 this.password = (e.target as HTMLInputElement).value; 245 }} 246 required 247 minlength="8" 248 placeholder="Enter new password (min 8 characters)" 249 > 250 </div> 251 252 <div class="form-group"> 253 <label for="confirm-password">Confirm Password</label> 254 <input 255 type="password" 256 id="confirm-password" 257 .value=${this.confirmPassword} 258 @input=${(e: Event) => { 259 this.confirmPassword = (e.target as HTMLInputElement).value; 260 }} 261 required 262 minlength="8" 263 placeholder="Confirm new password" 264 > 265 </div> 266 267 <button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}> 268 ${this.isSubmitting ? "Resetting..." : "Reset Password"} 269 </button> 270 </form> 271 272 <a href="/" class="back-link">Back to home</a> 273 </div> 274 `; 275 } 276 277 private async handleSubmit(e: Event) { 278 e.preventDefault(); 279 this.error = ""; 280 281 // Validate passwords match 282 if (this.password !== this.confirmPassword) { 283 this.error = "Passwords do not match"; 284 return; 285 } 286 287 // Validate password length 288 if (this.password.length < 8) { 289 this.error = "Password must be at least 8 characters"; 290 return; 291 } 292 293 this.isSubmitting = true; 294 295 try { 296 if (!this.email) { 297 throw new Error("Email not loaded"); 298 } 299 300 // Hash password client-side with user's email 301 const hashedPassword = await hashPasswordClient(this.password, this.email); 302 303 const response = await fetch("/api/auth/reset-password", { 304 method: "POST", 305 headers: { "Content-Type": "application/json" }, 306 body: JSON.stringify({ token: this.token, password: hashedPassword }), 307 }); 308 309 const data = await response.json(); 310 311 if (!response.ok) { 312 throw new Error(data.error || "Failed to reset password"); 313 } 314 315 // Show success message 316 this.isSuccess = true; 317 } catch (err) { 318 this.error = 319 err instanceof Error ? err.message : "Failed to reset password"; 320 } finally { 321 this.isSubmitting = false; 322 } 323 } 324}