🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add email verification when changing the email

+371 -20
+15
scripts/clear-rate-limits.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import db from "../src/db/schema"; 4 + 5 + console.log("🧹 Clearing all rate limit attempts..."); 6 + 7 + const result = db.run("DELETE FROM rate_limit_attempts"); 8 + 9 + const deletedCount = result.changes; 10 + 11 + if (deletedCount === 0) { 12 + console.log("ℹ️ No rate limit attempts to clear"); 13 + } else { 14 + console.log(`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? '' : 's'}`); 15 + }
+12 -3
src/components/user-modal.ts
··· 409 409 private async handleChangeEmail(e: Event) { 410 410 e.preventDefault(); 411 411 const form = e.target as HTMLFormElement; 412 - const input = form.querySelector("input") as HTMLInputElement; 412 + const input = form.querySelector('input[type="email"]') as HTMLInputElement; 413 + const checkbox = form.querySelector('input[type="checkbox"]') as HTMLInputElement; 413 414 const email = input.value.trim(); 415 + const skipVerification = checkbox?.checked || false; 414 416 415 417 if (!email || !email.includes("@")) { 416 418 alert("Please enter a valid email"); ··· 427 429 const res = await fetch(`/api/admin/users/${this.userId}/email`, { 428 430 method: "PUT", 429 431 headers: { "Content-Type": "application/json" }, 430 - body: JSON.stringify({ email }), 432 + body: JSON.stringify({ email, skipVerification }), 431 433 }); 432 434 433 435 if (!res.ok) { ··· 435 437 throw new Error(data.error || "Failed to update email"); 436 438 } 437 439 438 - alert("Email updated successfully"); 440 + const data = await res.json(); 441 + alert(data.message || "Email updated successfully"); 439 442 await this.loadUserDetails(); 440 443 this.dispatchEvent( 441 444 new CustomEvent("user-updated", { bubbles: true, composed: true }), ··· 638 641 <div class="form-group"> 639 642 <label class="form-label" for="new-email">New Email</label> 640 643 <input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}> 644 + </div> 645 + <div class="form-group" style="margin-top: 0.5rem;"> 646 + <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;"> 647 + <input type="checkbox" id="skip-verification" style="cursor: pointer;"> 648 + <span>Skip verification (use if user is locked out of email)</span> 649 + </label> 641 650 </div> 642 651 <button type="submit" class="btn btn-primary">Update Email</button> 643 652 </form>
+50 -5
src/components/user-settings.ts
··· 61 61 @state() addingPasskey = false; 62 62 @state() emailNotificationsEnabled = true; 63 63 @state() deletingAccount = false; 64 + @state() emailChangeMessage = ""; 65 + @state() pendingEmailChange = ""; 66 + @state() updatingEmail = false; 64 67 65 68 static override styles = css` 66 69 :host { ··· 286 289 opacity: 0.7; 287 290 margin-bottom: 1.5rem; 288 291 line-height: 1.5; 292 + } 293 + 294 + .success-message { 295 + padding: 1rem; 296 + background: rgba(76, 175, 80, 0.1); 297 + border: 1px solid rgba(76, 175, 80, 0.3); 298 + border-radius: 0.5rem; 299 + color: var(--text); 300 + } 301 + 302 + .spinner { 303 + display: inline-block; 304 + width: 1rem; 305 + height: 1rem; 306 + border: 2px solid rgba(255, 255, 255, 0.3); 307 + border-top-color: white; 308 + border-radius: 50%; 309 + animation: spin 0.6s linear infinite; 310 + } 311 + 312 + @keyframes spin { 313 + to { 314 + transform: rotate(360deg); 315 + } 289 316 } 290 317 291 318 .session-list { ··· 696 723 697 724 async handleUpdateEmail() { 698 725 this.error = ""; 726 + this.emailChangeMessage = ""; 699 727 if (!this.newEmail) { 700 728 this.error = "Email required"; 701 729 return; 702 730 } 703 731 732 + this.updatingEmail = true; 704 733 try { 705 734 const response = await fetch("/api/user/email", { 706 735 method: "PUT", 707 736 headers: { "Content-Type": "application/json" }, 708 737 body: JSON.stringify({ email: this.newEmail }), 709 738 }); 739 + 740 + const data = await response.json(); 710 741 711 742 if (!response.ok) { 712 - const data = await response.json(); 713 743 this.error = data.error || "Failed to update email"; 714 744 return; 715 745 } 716 746 717 - // Reload user data 718 - await this.loadUser(); 747 + // Show success message with pending email 748 + this.emailChangeMessage = data.message || "Verification email sent"; 749 + this.pendingEmailChange = data.pendingEmail || this.newEmail; 719 750 this.editingEmail = false; 720 751 this.newEmail = ""; 721 752 } catch { 722 753 this.error = "Failed to update email"; 754 + } finally { 755 + this.updatingEmail = false; 723 756 } 724 757 } 725 758 ··· 1032 1065 <div class="field-group"> 1033 1066 <label class="field-label">Email</label> 1034 1067 ${ 1035 - this.editingEmail 1068 + this.emailChangeMessage 1069 + ? html` 1070 + <div class="success-message" style="margin-bottom: 1rem;"> 1071 + ${this.emailChangeMessage} 1072 + ${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ''} 1073 + </div> 1074 + <div class="field-row"> 1075 + <div class="field-value">${this.user.email}</div> 1076 + </div> 1077 + ` 1078 + : this.editingEmail 1036 1079 ? html` 1037 1080 <div style="display: flex; gap: 0.5rem; align-items: center;"> 1038 1081 <input ··· 1047 1090 <button 1048 1091 class="btn btn-affirmative btn-small" 1049 1092 @click=${this.handleUpdateEmail} 1093 + ?disabled=${this.updatingEmail} 1050 1094 > 1051 - Save 1095 + ${this.updatingEmail ? html`<span class="spinner"></span>` : 'Save'} 1052 1096 </button> 1053 1097 <button 1054 1098 class="btn btn-neutral btn-small" ··· 1069 1113 @click=${() => { 1070 1114 this.editingEmail = true; 1071 1115 this.newEmail = this.user?.email ?? ""; 1116 + this.emailChangeMessage = ""; 1072 1117 }} 1073 1118 > 1074 1119 Change
+19
src/db/schema.ts
··· 254 254 ALTER TABLE users ADD COLUMN email_notifications_enabled BOOLEAN DEFAULT 1; 255 255 `, 256 256 }, 257 + { 258 + version: 9, 259 + name: "Add email change tokens table", 260 + sql: ` 261 + -- Email change tokens table 262 + CREATE TABLE IF NOT EXISTS email_change_tokens ( 263 + id TEXT PRIMARY KEY, 264 + user_id INTEGER NOT NULL, 265 + new_email TEXT NOT NULL, 266 + token TEXT NOT NULL UNIQUE, 267 + expires_at INTEGER NOT NULL, 268 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 269 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 270 + ); 271 + 272 + CREATE INDEX IF NOT EXISTS idx_email_change_tokens_user_id ON email_change_tokens(user_id); 273 + CREATE INDEX IF NOT EXISTS idx_email_change_tokens_token ON email_change_tokens(token); 274 + `, 275 + }, 257 276 ]; 258 277 259 278 function getCurrentVersion(): number {
+80 -12
src/index.ts
··· 32 32 createPasswordResetToken, 33 33 verifyPasswordResetToken, 34 34 consumePasswordResetToken, 35 + createEmailChangeToken, 36 + verifyEmailChangeToken, 37 + consumeEmailChangeToken, 35 38 } from "./lib/auth"; 36 39 import { 37 40 addToWaitlist, ··· 82 85 import { 83 86 verifyEmailTemplate, 84 87 passwordResetTemplate, 88 + emailChangeTemplate, 85 89 } from "./lib/email-templates"; 86 90 import adminHTML from "./pages/admin.html"; 87 91 import checkoutHTML from "./pages/checkout.html"; ··· 1056 1060 if (!email) { 1057 1061 return Response.json({ error: "Email required" }, { status: 400 }); 1058 1062 } 1063 + 1064 + // Check if email is already in use 1065 + const existingUser = getUserByEmail(email); 1066 + if (existingUser) { 1067 + return Response.json( 1068 + { error: "Email already in use" }, 1069 + { status: 400 }, 1070 + ); 1071 + } 1072 + 1059 1073 try { 1060 - updateUserEmail(user.id, email); 1061 - return Response.json({ success: true }); 1062 - } catch (err: unknown) { 1063 - const error = err as { message?: string }; 1064 - if (error.message?.includes("UNIQUE constraint failed")) { 1065 - return Response.json( 1066 - { error: "Email already in use" }, 1067 - { status: 400 }, 1068 - ); 1069 - } 1074 + // Create email change token 1075 + const token = createEmailChangeToken(user.id, email); 1076 + 1077 + // Send verification email to the CURRENT address 1078 + const origin = process.env.ORIGIN || "http://localhost:3000"; 1079 + const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 1080 + 1081 + await sendEmail({ 1082 + to: user.email, 1083 + subject: "Verify your email change", 1084 + html: emailChangeTemplate({ 1085 + name: user.name, 1086 + currentEmail: user.email, 1087 + newEmail: email, 1088 + verifyLink: verifyUrl, 1089 + }), 1090 + }); 1091 + 1092 + return Response.json({ 1093 + success: true, 1094 + message: `Verification email sent to ${user.email}`, 1095 + pendingEmail: email 1096 + }); 1097 + } catch (error) { 1098 + console.error("[Email] Failed to send email change verification:", error); 1070 1099 return Response.json( 1071 - { error: "Failed to update email" }, 1100 + { error: "Failed to send verification email" }, 1072 1101 { status: 500 }, 1073 1102 ); 1103 + } 1104 + }, 1105 + }, 1106 + "/api/user/email/verify": { 1107 + GET: async (req) => { 1108 + try { 1109 + const url = new URL(req.url); 1110 + const token = url.searchParams.get("token"); 1111 + 1112 + if (!token) { 1113 + return Response.redirect("/settings?tab=account&error=invalid-token", 302); 1114 + } 1115 + 1116 + const result = verifyEmailChangeToken(token); 1117 + 1118 + if (!result) { 1119 + return Response.redirect("/settings?tab=account&error=expired-token", 302); 1120 + } 1121 + 1122 + // Update the user's email 1123 + updateUserEmail(result.userId, result.newEmail); 1124 + 1125 + // Consume the token 1126 + consumeEmailChangeToken(token); 1127 + 1128 + // Redirect to settings with success message 1129 + return Response.redirect("/settings?tab=account&success=email-changed", 302); 1130 + } catch (error) { 1131 + console.error("[Email] Email change verification error:", error); 1132 + return Response.redirect("/settings?tab=account&error=verification-failed", 302); 1074 1133 } 1075 1134 }, 1076 1135 }, ··· 2308 2367 } 2309 2368 2310 2369 const body = await req.json(); 2311 - const { email } = body as { email: string }; 2370 + const { email, skipVerification } = body as { email: string; skipVerification?: boolean }; 2312 2371 2313 2372 if (!email || !email.includes("@")) { 2314 2373 return Response.json( ··· 2329 2388 { error: "Email already in use" }, 2330 2389 { status: 400 }, 2331 2390 ); 2391 + } 2392 + 2393 + if (skipVerification) { 2394 + // Admin override: change email immediately without verification 2395 + updateUserEmailAddress(userId, email); 2396 + return Response.json({ 2397 + success: true, 2398 + message: "Email updated immediately (verification skipped)" 2399 + }); 2332 2400 } 2333 2401 2334 2402 updateUserEmailAddress(userId, email);
+38
src/lib/auth.ts
··· 404 404 db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]); 405 405 } 406 406 407 + /** 408 + * Email change functions 409 + */ 410 + 411 + export function createEmailChangeToken(userId: number, newEmail: string): string { 412 + const token = crypto.randomUUID(); 413 + const id = crypto.randomUUID(); 414 + const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours 415 + 416 + // Delete any existing email change tokens for this user 417 + db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [userId]); 418 + 419 + db.run( 420 + "INSERT INTO email_change_tokens (id, user_id, new_email, token, expires_at) VALUES (?, ?, ?, ?, ?)", 421 + [id, userId, newEmail, token, expiresAt], 422 + ); 423 + 424 + return token; 425 + } 426 + 427 + export function verifyEmailChangeToken(token: string): { userId: number; newEmail: string } | null { 428 + const now = Math.floor(Date.now() / 1000); 429 + 430 + const result = db 431 + .query<{ user_id: number; new_email: string }, [string, number]>( 432 + "SELECT user_id, new_email FROM email_change_tokens WHERE token = ? AND expires_at > ?", 433 + ) 434 + .get(token, now); 435 + 436 + if (!result) return null; 437 + 438 + return { userId: result.user_id, newEmail: result.new_email }; 439 + } 440 + 441 + export function consumeEmailChangeToken(token: string): void { 442 + db.run("DELETE FROM email_change_tokens WHERE token = ?", [token]); 443 + } 444 + 407 445 export function isUserAdmin(userId: number): boolean { 408 446 const result = db 409 447 .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
+96
src/lib/email-change.test.ts
··· 1 + import { test, expect } from "bun:test"; 2 + import db from "../db/schema"; 3 + import { 4 + createUser, 5 + createEmailChangeToken, 6 + verifyEmailChangeToken, 7 + consumeEmailChangeToken, 8 + updateUserEmail, 9 + getUserByEmail, 10 + } from "./auth"; 11 + 12 + test("email change token lifecycle", async () => { 13 + // Create a test user with unique email 14 + const timestamp = Date.now(); 15 + const user = await createUser(`test-email-change-${timestamp}@example.com`, "password123", "Test User"); 16 + 17 + // Create an email change token 18 + const newEmail = `new-email-${timestamp}@example.com`; 19 + const token = createEmailChangeToken(user.id, newEmail); 20 + 21 + expect(token).toBeTruthy(); 22 + expect(token.length).toBeGreaterThan(0); 23 + 24 + // Verify the token 25 + const result = verifyEmailChangeToken(token); 26 + expect(result).toBeTruthy(); 27 + expect(result?.userId).toBe(user.id); 28 + expect(result?.newEmail).toBe(newEmail); 29 + 30 + // Update the email 31 + updateUserEmail(result!.userId, result!.newEmail); 32 + 33 + // Consume the token 34 + consumeEmailChangeToken(token); 35 + 36 + // Verify the email was updated 37 + const updatedUser = getUserByEmail(newEmail); 38 + expect(updatedUser).toBeTruthy(); 39 + expect(updatedUser?.id).toBe(user.id); 40 + expect(updatedUser?.email).toBe(newEmail); 41 + 42 + // Verify the token can't be used again 43 + const result2 = verifyEmailChangeToken(token); 44 + expect(result2).toBeNull(); 45 + 46 + // Clean up 47 + db.run("DELETE FROM users WHERE id = ?", [user.id]); 48 + }); 49 + 50 + test("email change token expires", async () => { 51 + // Create a test user with unique email 52 + const timestamp = Date.now(); 53 + const user = await createUser(`test-expire-${timestamp}@example.com`, "password123", "Test User"); 54 + 55 + // Create an email change token 56 + const newEmail = `new-expire-${timestamp}@example.com`; 57 + const token = createEmailChangeToken(user.id, newEmail); 58 + 59 + // Manually expire the token 60 + db.run("UPDATE email_change_tokens SET expires_at = ? WHERE token = ?", [ 61 + Math.floor(Date.now() / 1000) - 1, 62 + token, 63 + ]); 64 + 65 + // Verify the token is expired 66 + const result = verifyEmailChangeToken(token); 67 + expect(result).toBeNull(); 68 + 69 + // Clean up 70 + db.run("DELETE FROM users WHERE id = ?", [user.id]); 71 + }); 72 + 73 + test("only one email change token per user", async () => { 74 + // Create a test user with unique email 75 + const timestamp = Date.now(); 76 + const user = await createUser(`test-single-token-${timestamp}@example.com`, "password123", "Test User"); 77 + 78 + // Create first token 79 + const token1 = createEmailChangeToken(user.id, `email1-${timestamp}@example.com`); 80 + 81 + // Create second token (should delete first) 82 + const token2 = createEmailChangeToken(user.id, `email2-${timestamp}@example.com`); 83 + 84 + // First token should be invalid 85 + const result1 = verifyEmailChangeToken(token1); 86 + expect(result1).toBeNull(); 87 + 88 + // Second token should work 89 + const result2 = verifyEmailChangeToken(token2); 90 + expect(result2).toBeTruthy(); 91 + expect(result2?.newEmail).toBe(`email2-${timestamp}@example.com`); 92 + 93 + // Clean up 94 + db.run("DELETE FROM users WHERE id = ?", [user.id]); 95 + db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [user.id]); 96 + });
+61
src/lib/email-templates.ts
··· 231 231 </html> 232 232 `.trim(); 233 233 } 234 + 235 + interface EmailChangeOptions { 236 + name: string | null; 237 + currentEmail: string; 238 + newEmail: string; 239 + verifyLink: string; 240 + } 241 + 242 + export function emailChangeTemplate(options: EmailChangeOptions): string { 243 + const greeting = options.name ? `Hi ${options.name}` : "Hi there"; 244 + 245 + return ` 246 + <!DOCTYPE html> 247 + <html lang="en"> 248 + <head> 249 + <meta charset="UTF-8"> 250 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 251 + <title>Verify Email Change - Thistle</title> 252 + <style>${baseStyles}</style> 253 + </head> 254 + <body> 255 + <div class="container"> 256 + <div class="header"> 257 + <h1>🪻 Thistle</h1> 258 + </div> 259 + <div class="content"> 260 + <h2>${greeting}!</h2> 261 + <p>You requested to change your email address.</p> 262 + 263 + <div class="info-box"> 264 + <p class="info-box-label">Current Email</p> 265 + <p class="info-box-value">${options.currentEmail}</p> 266 + <hr class="info-box-divider"> 267 + <p class="info-box-label">New Email</p> 268 + <p class="info-box-value">${options.newEmail}</p> 269 + </div> 270 + 271 + <p>Click the button below to confirm this change:</p> 272 + 273 + <p style="text-align: center; margin-top: 1.5rem; margin-bottom: 0;"> 274 + <a href="${options.verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email Change</a> 275 + </p> 276 + 277 + <p style="color: #4f5d75; font-size: 0.875rem; margin-top: 1.5rem;"> 278 + If the button doesn't work, copy and paste this link into your browser:<br> 279 + <a href="${options.verifyLink}" style="color: #4f5d75; word-break: break-all;">${options.verifyLink}</a> 280 + </p> 281 + 282 + <p style="color: #4f5d75; font-size: 0.875rem;"> 283 + This link will expire in 24 hours. 284 + </p> 285 + </div> 286 + <div class="footer"> 287 + <p>If you didn't request this change, please ignore this email and your email address will remain unchanged.</p> 288 + </div> 289 + </div> 290 + </body> 291 + </html> 292 + `.trim(); 293 + } 294 +