🪻 distributed transcription service thistle.dunkirk.sh

feat: make a better reset password flow

dunkirk.sh bbd5c004 6789cc46

verified
+447 -162
+324
src/components/reset-password-form.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { hashPasswordClient } from "../lib/client-auth"; 4 + 5 + @customElement("reset-password-form") 6 + export 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 + }
+22 -26
src/components/user-modal.ts
··· 233 233 color: #991b1b; 234 234 } 235 235 236 + .info-text { 237 + color: var(--text); 238 + font-size: 0.875rem; 239 + margin: 0 0 1rem 0; 240 + line-height: 1.5; 241 + opacity: 0.8; 242 + } 243 + 236 244 .session-list, .passkey-list { 237 245 list-style: none; 238 246 padding: 0; ··· 442 450 443 451 private async handleChangePassword(e: Event) { 444 452 e.preventDefault(); 445 - const form = e.target as HTMLFormElement; 446 - const input = form.querySelector("input") as HTMLInputElement; 447 - const password = input.value; 448 - 449 - if (password.length < 8) { 450 - alert("Password must be at least 8 characters"); 451 - return; 452 - } 453 453 454 454 if ( 455 455 !confirm( 456 - "Are you sure you want to change this user's password? This will log them out of all devices.", 456 + "Send a password reset email to this user? They will receive a link to set a new password.", 457 457 ) 458 458 ) { 459 459 return; 460 460 } 461 461 462 + const form = e.target as HTMLFormElement; 462 463 const submitBtn = form.querySelector( 463 464 'button[type="submit"]', 464 465 ) as HTMLButtonElement; 465 466 submitBtn.disabled = true; 466 - submitBtn.textContent = "Updating..."; 467 + submitBtn.textContent = "Sending..."; 467 468 468 469 try { 469 - const res = await fetch(`/api/admin/users/${this.userId}/password`, { 470 - method: "PUT", 470 + const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, { 471 + method: "POST", 471 472 headers: { "Content-Type": "application/json" }, 472 - body: JSON.stringify({ password }), 473 473 }); 474 474 475 475 if (!res.ok) { 476 - throw new Error("Failed to update password"); 476 + const data = await res.json(); 477 + throw new Error(data.error || "Failed to send password reset email"); 477 478 } 478 479 479 480 alert( 480 - "Password updated successfully. User has been logged out of all devices.", 481 + "Password reset email sent successfully. The user will receive a link to set a new password.", 481 482 ); 482 - input.value = ""; 483 - await this.loadUserDetails(); 484 - } catch { 485 - alert("Failed to update password"); 483 + } catch (err) { 484 + this.error = err instanceof Error ? err.message : "Failed to send password reset email"; 486 485 } finally { 487 486 submitBtn.disabled = false; 488 - submitBtn.textContent = "Update Password"; 487 + submitBtn.textContent = "Send Reset Email"; 489 488 } 490 489 } 491 490 ··· 645 644 </div> 646 645 647 646 <div class="detail-section"> 648 - <h3 class="detail-section-title">Change Password</h3> 647 + <h3 class="detail-section-title">Password Reset</h3> 648 + <p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p> 649 649 <form @submit=${this.handleChangePassword}> 650 - <div class="form-group"> 651 - <label class="form-label" for="new-password">New Password</label> 652 - <input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)"> 653 - </div> 654 - <button type="submit" class="btn btn-primary">Update Password</button> 650 + <button type="submit" class="btn btn-primary">Send Reset Email</button> 655 651 </form> 656 652 </div> 657 653
+1 -1
src/index.test.README.md
··· 70 70 - `PUT /api/admin/users/:id/role` - Update user role 71 71 - `PUT /api/admin/users/:id/name` - Update user name 72 72 - `PUT /api/admin/users/:id/email` - Update user email 73 - - `PUT /api/admin/users/:id/password` - Update user password 73 + - `POST /api/admin/users/:id/password-reset` - Send password reset email 74 74 - `GET /api/admin/users/:id/sessions` - List user sessions 75 75 - `DELETE /api/admin/users/:id/sessions` - Delete all user sessions 76 76 - `DELETE /api/admin/users/:id/sessions/:sessionId` - Delete specific session
+69 -11
src/index.ts
··· 664 664 }, 665 665 }, 666 666 "/api/auth/reset-password": { 667 + GET: async (req) => { 668 + try { 669 + const url = new URL(req.url); 670 + const token = url.searchParams.get("token"); 671 + 672 + if (!token) { 673 + return Response.json( 674 + { error: "Token required" }, 675 + { status: 400 }, 676 + ); 677 + } 678 + 679 + const userId = verifyPasswordResetToken(token); 680 + if (!userId) { 681 + return Response.json( 682 + { error: "Invalid or expired reset token" }, 683 + { status: 400 }, 684 + ); 685 + } 686 + 687 + // Get user's email for client-side password hashing 688 + const user = db 689 + .query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?") 690 + .get(userId); 691 + 692 + if (!user) { 693 + return Response.json({ error: "User not found" }, { status: 404 }); 694 + } 695 + 696 + return Response.json({ email: user.email }); 697 + } catch (error) { 698 + console.error("[Email] Get reset token info error:", error); 699 + return Response.json( 700 + { error: "Failed to verify token" }, 701 + { status: 500 }, 702 + ); 703 + } 704 + }, 667 705 POST: async (req) => { 668 706 try { 669 707 const body = await req.json(); ··· 2162 2200 } 2163 2201 }, 2164 2202 }, 2165 - "/api/admin/users/:id/password": { 2166 - PUT: async (req) => { 2203 + "/api/admin/users/:id/password-reset": { 2204 + POST: async (req) => { 2167 2205 try { 2168 2206 requireAdmin(req); 2169 2207 const userId = Number.parseInt(req.params.id, 10); ··· 2171 2209 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2172 2210 } 2173 2211 2174 - const body = await req.json(); 2175 - const { password } = body as { password: string }; 2212 + // Get user details 2213 + const user = db 2214 + .query< 2215 + { id: number; email: string; name: string | null }, 2216 + [number] 2217 + >("SELECT id, email, name FROM users WHERE id = ?") 2218 + .get(userId); 2176 2219 2177 - if (!password || password.length < 8) { 2178 - return Response.json( 2179 - { error: "Password must be at least 8 characters" }, 2180 - { status: 400 }, 2181 - ); 2220 + if (!user) { 2221 + return Response.json({ error: "User not found" }, { status: 404 }); 2182 2222 } 2183 2223 2184 - await updateUserPassword(userId, password); 2185 - return Response.json({ success: true }); 2224 + // Create password reset token 2225 + const origin = req.headers.get("origin") || "http://localhost:3000"; 2226 + const resetToken = createPasswordResetToken(user.id); 2227 + const resetLink = `${origin}/reset-password?token=${resetToken}`; 2228 + 2229 + // Send password reset email 2230 + await sendEmail({ 2231 + to: user.email, 2232 + subject: "Reset your password - Thistle", 2233 + html: passwordResetTemplate({ 2234 + name: user.name, 2235 + resetLink, 2236 + }), 2237 + }); 2238 + 2239 + return Response.json({ 2240 + success: true, 2241 + message: "Password reset email sent" 2242 + }); 2186 2243 } catch (error) { 2244 + console.error("[Admin] Password reset error:", error); 2187 2245 return handleError(error); 2188 2246 } 2189 2247 },
+31 -124
src/pages/reset-password.html
··· 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 7 <title>Reset Password - Thistle</title> 8 - <link rel="icon" 9 - href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>"> 8 + <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 + <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 + <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 + <link rel="manifest" href="../../public/favicon/site.webmanifest"> 10 12 <link rel="stylesheet" href="../styles/main.css"> 13 + <style> 14 + main { 15 + display: flex; 16 + align-items: center; 17 + justify-content: center; 18 + padding: 4rem 1rem; 19 + } 20 + </style> 11 21 </head> 12 22 13 23 <body> 14 - <auth-component></auth-component> 15 - 24 + <header> 25 + <div class="header-content"> 26 + <a href="/" class="site-title"> 27 + <img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo"> 28 + <span>Thistle</span> 29 + </a> 30 + <auth-component></auth-component> 31 + </div> 32 + </header> 33 + 16 34 <main> 17 - <div class="container" style="max-width: 28rem; margin: 4rem auto; padding: 0 1rem;"> 18 - <div class="reset-card" style="background: var(--white); border: 1px solid var(--silver); border-radius: 0.5rem; padding: 2rem;"> 19 - <h1 style="margin-top: 0; text-align: center;">🪻 Reset Password</h1> 20 - 21 - <div id="form-container"> 22 - <form id="reset-form"> 23 - <div style="margin-bottom: 1.5rem;"> 24 - <label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">New Password</label> 25 - <input 26 - type="password" 27 - id="password" 28 - required 29 - minlength="8" 30 - style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;" 31 - > 32 - </div> 33 - 34 - <div style="margin-bottom: 1.5rem;"> 35 - <label for="confirm-password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Confirm Password</label> 36 - <input 37 - type="password" 38 - id="confirm-password" 39 - required 40 - minlength="8" 41 - style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;" 42 - > 43 - </div> 44 - 45 - <div id="error-message" style="display: none; color: var(--coral); margin-bottom: 1rem; padding: 0.75rem; background: #fef2f2; border-radius: 0.25rem;"></div> 46 - 47 - <button 48 - type="submit" 49 - id="submit-btn" 50 - style="width: 100%; padding: 0.75rem; background: var(--accent); color: var(--white); border: none; border-radius: 0.25rem; font-size: 1rem; font-weight: 500; cursor: pointer;" 51 - > 52 - Reset Password 53 - </button> 54 - </form> 55 - 56 - <div style="text-align: center; margin-top: 1.5rem;"> 57 - <a href="/" style="color: var(--primary); text-decoration: none;">Back to home</a> 58 - </div> 59 - </div> 60 - 61 - <div id="success-message" style="display: none; text-align: center;"> 62 - <div style="color: var(--primary); margin-bottom: 1rem;"> 63 - ✓ Password reset successfully! 64 - </div> 65 - <a href="/" style="color: var(--accent); font-weight: 500; text-decoration: none;">Go to home</a> 66 - </div> 67 - </div> 68 - </div> 35 + <reset-password-form id="reset-form"></reset-password-form> 69 36 </main> 70 37 71 38 <script type="module" src="../components/auth.ts"></script> 39 + <script type="module" src="../components/reset-password-form.ts"></script> 72 40 <script type="module"> 73 - import { hashPasswordClient } from '../lib/client-auth.ts'; 74 - 75 - const form = document.getElementById('reset-form'); 76 - const passwordInput = document.getElementById('password'); 77 - const confirmPasswordInput = document.getElementById('confirm-password'); 78 - const submitBtn = document.getElementById('submit-btn'); 79 - const errorMessage = document.getElementById('error-message'); 80 - const formContainer = document.getElementById('form-container'); 81 - const successMessage = document.getElementById('success-message'); 82 - 83 - // Get token from URL 41 + // Wait for component to be defined before setting token 42 + await customElements.whenDefined('reset-password-form'); 43 + 44 + // Get token from URL and pass to component 84 45 const urlParams = new URLSearchParams(window.location.search); 85 46 const token = urlParams.get('token'); 86 - 87 - if (!token) { 88 - errorMessage.textContent = 'Invalid or missing reset token'; 89 - errorMessage.style.display = 'block'; 90 - form.style.display = 'none'; 47 + const resetForm = document.getElementById('reset-form'); 48 + if (resetForm) { 49 + resetForm.token = token; 91 50 } 92 - 93 - form?.addEventListener('submit', async (e) => { 94 - e.preventDefault(); 95 - 96 - const password = passwordInput.value; 97 - const confirmPassword = confirmPasswordInput.value; 98 - 99 - // Validate passwords match 100 - if (password !== confirmPassword) { 101 - errorMessage.textContent = 'Passwords do not match'; 102 - errorMessage.style.display = 'block'; 103 - return; 104 - } 105 - 106 - // Validate password length 107 - if (password.length < 8) { 108 - errorMessage.textContent = 'Password must be at least 8 characters'; 109 - errorMessage.style.display = 'block'; 110 - return; 111 - } 112 - 113 - errorMessage.style.display = 'none'; 114 - submitBtn.disabled = true; 115 - submitBtn.textContent = 'Resetting...'; 116 - 117 - try { 118 - // Hash password client-side 119 - const hashedPassword = await hashPasswordClient(password); 120 - 121 - const response = await fetch('/api/auth/reset-password', { 122 - method: 'POST', 123 - headers: { 'Content-Type': 'application/json' }, 124 - body: JSON.stringify({ token, password: hashedPassword }), 125 - }); 126 - 127 - const data = await response.json(); 128 - 129 - if (!response.ok) { 130 - throw new Error(data.error || 'Failed to reset password'); 131 - } 132 - 133 - // Show success message 134 - formContainer.style.display = 'none'; 135 - successMessage.style.display = 'block'; 136 - 137 - } catch (error) { 138 - errorMessage.textContent = error.message || 'Failed to reset password'; 139 - errorMessage.style.display = 'block'; 140 - submitBtn.disabled = false; 141 - submitBtn.textContent = 'Reset Password'; 142 - } 143 - }); 144 51 </script> 145 52 </body> 146 53