🪻 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 support

+1745 -280
+9
.env.example
··· 33 33 34 34 # Environment (set to 'production' in production) 35 35 NODE_ENV=development 36 + 37 + # Email Configuration (MailChannels) 38 + # DKIM private key for email authentication (required for sending emails) 39 + # Generate: openssl genrsa -out dkim-private.pem 2048 40 + # Then add TXT record: mailchannels._domainkey.yourdomain.com 41 + DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----" 42 + DKIM_DOMAIN=thistle.app 43 + SMTP_FROM_EMAIL=noreply@thistle.app 44 + SMTP_FROM_NAME=Thistle
+1
.gitignore
··· 3 3 uploads/ 4 4 transcripts/ 5 5 .env 6 + *.pem
-264
docs/CLASS_SYSTEM_SPEC.md
··· 1 - # Class System Specification 2 - 3 - ## Overview 4 - 5 - Restructure Thistle from individual transcript management to class-based transcript organization. Users will manage transcripts grouped by classes, with scheduled meeting times and selective transcription. 6 - 7 - ## User Flow 8 - 9 - ### 1. Classes Page (Home) 10 - - Replaces the transcript page as the main view after signup 11 - - Displays grid of class cards organized by semester/year 12 - - Each section (semester/year combo) separated by horizontal rules 13 - - Each card shows: 14 - - Course code (e.g., "CS 101") 15 - - Course name (e.g., "Introduction to Computer Science") 16 - - Professor name 17 - - Semester and year (e.g., "Fall 2024") 18 - - Archive indicator (if archived) 19 - - Final card in grid is "Register for Class" with centered plus icon 20 - - Empty state: Only shows register button if user has no classes 21 - 22 - ### 2. Individual Class Page (`/classes/:id`) 23 - - Lists all recordings and transcripts for the class 24 - - Shows meeting schedule (flexible text, e.g., "Monday Lecture", "Wednesday Lab") 25 - - Displays recordings with statuses: 26 - - **Pending**: Uploaded but not selected for transcription 27 - - **Selected**: Marked for transcription by admin 28 - - **Transcribed**: Processing complete, ready to view 29 - - **Failed**: Transcription failed 30 - - Upload button to add new recordings 31 - - Each recording tagged with meeting time 32 - 33 - ### 3. Recording Upload 34 - - Any enrolled student can upload recordings 35 - - Must select which meeting time the recording is for 36 - - Recording enters "pending" state 37 - - Does not auto-transcribe 38 - 39 - ### 4. Admin Workflow 40 - - Admin views pending recordings 41 - - Selects specific recording to transcribe for each meeting 42 - - Only selected recordings get processed 43 - - Can manage classes (create, archive, enrollments) 44 - 45 - ## Database Schema 46 - 47 - ### Classes Table 48 - ```sql 49 - CREATE TABLE classes ( 50 - id TEXT PRIMARY KEY, -- stable random ID (nanoid or similar) 51 - course_code TEXT NOT NULL, -- e.g., "CS 101" 52 - name TEXT NOT NULL, -- e.g., "Introduction to Computer Science" 53 - professor TEXT NOT NULL, 54 - semester TEXT NOT NULL, -- e.g., "Fall", "Spring", "Summer" 55 - year INTEGER NOT NULL, -- e.g., 2024 56 - archived BOOLEAN DEFAULT FALSE, 57 - created_at INTEGER NOT NULL 58 - ); 59 - ``` 60 - 61 - ### Class Members Table 62 - ```sql 63 - CREATE TABLE class_members ( 64 - class_id TEXT NOT NULL, 65 - user_id TEXT NOT NULL, 66 - enrolled_at INTEGER NOT NULL, 67 - PRIMARY KEY (class_id, user_id), 68 - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 69 - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 70 - ); 71 - ``` 72 - 73 - ### Meeting Times Table 74 - ```sql 75 - CREATE TABLE meeting_times ( 76 - id TEXT PRIMARY KEY, 77 - class_id TEXT NOT NULL, 78 - label TEXT NOT NULL, -- flexible text: "Monday Lecture", "Wednesday Lab", etc. 79 - created_at INTEGER NOT NULL, 80 - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 81 - ); 82 - ``` 83 - 84 - ### Updated Transcripts Table 85 - ```sql 86 - -- Add new columns to existing transcripts table: 87 - ALTER TABLE transcripts ADD COLUMN class_id TEXT; 88 - ALTER TABLE transcripts ADD COLUMN meeting_time_id TEXT; 89 - ALTER TABLE transcripts ADD COLUMN status TEXT DEFAULT 'pending'; 90 - -- status: 'pending' | 'selected' | 'transcribed' | 'failed' 91 - 92 - -- Add foreign keys: 93 - FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 94 - FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL 95 - ``` 96 - 97 - **Note**: Add indexes for performance: 98 - - `class_members(user_id)` - lookup user's classes 99 - - `class_members(class_id)` - lookup class members 100 - - `transcripts(class_id)` - lookup class transcripts 101 - - `transcripts(status)` - filter by status 102 - - `meeting_times(class_id)` - lookup class schedule 103 - 104 - ## Permissions 105 - 106 - ### Class Access 107 - - Users can only view classes they're enrolled in 108 - - Admins can view all classes 109 - - Non-enrolled users get 403 when accessing `/classes/:id` 110 - 111 - ### Recording Permissions 112 - - **Upload**: Any enrolled student can upload recordings 113 - - **Delete**: Students can delete their own recordings 114 - - **Select for transcription**: Admin only 115 - - **View**: All enrolled students can view all transcripts in their classes 116 - 117 - ### Class Management 118 - - **Create**: Admin only (via admin UI) 119 - - **Archive**: Admin only (via admin UI) 120 - - **Enroll students**: Admin only (via admin UI) 121 - - **Remove students**: Admin only (via admin UI) 122 - 123 - ## Archive Behavior 124 - 125 - When a class is archived: 126 - - Students can still view the class and all transcripts 127 - - No new recordings can be uploaded 128 - - No recordings can be deleted 129 - - No transcription selection allowed 130 - - No enrollment changes 131 - - Class appears with archive indicator in UI 132 - - Organized with active classes by semester/year 133 - 134 - ## API Endpoints 135 - 136 - ### Classes 137 - - `GET /api/classes` - List user's classes (grouped by semester/year) 138 - - `GET /api/classes/:id` - Get class details (info, meeting times, transcripts) 139 - - `POST /api/classes` (admin) - Create new class 140 - - `PUT /api/classes/:id/archive` (admin) - Archive/unarchive class 141 - - `DELETE /api/classes/:id` (admin) - Delete class 142 - 143 - ### Class Members 144 - - `POST /api/classes/:id/members` (admin) - Enroll student(s) 145 - - `DELETE /api/classes/:id/members/:userId` (admin) - Remove student 146 - - `GET /api/classes/:id/members` (admin) - List class members 147 - 148 - ### Meeting Times 149 - - `GET /api/classes/:id/meetings` - List meeting times 150 - - `POST /api/classes/:id/meetings` (admin) - Create meeting time 151 - - `PUT /api/meetings/:id` (admin) - Update meeting time label 152 - - `DELETE /api/meetings/:id` (admin) - Delete meeting time 153 - 154 - ### Recordings/Transcripts 155 - - `GET /api/classes/:id/transcripts` - List all transcripts for class 156 - - `POST /api/classes/:id/recordings` - Upload recording (enrolled students) 157 - - `PUT /api/transcripts/:id/select` (admin) - Mark recording for transcription 158 - - `DELETE /api/transcripts/:id` - Delete recording (owner or admin) 159 - - `GET /api/transcripts/:id` - View transcript (enrolled students) 160 - 161 - ## Frontend Components 162 - 163 - ### Pages 164 - - `/classes` - Classes grid (home page, replaces transcripts page) 165 - - `/classes/:id` - Individual class view 166 - - `/admin` - Update to include class management 167 - 168 - ### New Components 169 - - `class-card.ts` - Class card component 170 - - `register-card.ts` - Register for class card (plus icon) 171 - - `class-detail.ts` - Individual class page 172 - - `recording-upload.ts` - Recording upload form 173 - - `recording-list.ts` - List of recordings with status 174 - - `admin-classes.ts` - Admin class management interface 175 - 176 - ### Navigation Updates 177 - - Remove transcript page links 178 - - Add classes link (make it home) 179 - - Update auth redirect after signup to `/classes` 180 - 181 - ## Migration Strategy 182 - 183 - **Breaking change**: Reset database schema to consolidate all migrations. 184 - 185 - 1. Export any critical production data (if needed) 186 - 2. Drop all tables 187 - 3. Consolidate migrations in `src/db/schema.ts`: 188 - - Include all previous migrations 189 - - Add new class system tables 190 - - Add new columns to transcripts 191 - 4. Restart with version 1 192 - 5. Existing transcripts will be lost (acceptable for this phase) 193 - 194 - ## Admin UI Updates 195 - 196 - ### Class Management Tab 197 - - Create new class form: 198 - - Course code 199 - - Course name 200 - - Professor 201 - - Semester dropdown (Fall/Spring/Summer/Winter) 202 - - Year input 203 - - List all classes (with archive status) 204 - - Archive/unarchive button per class 205 - - Delete class button 206 - 207 - ### Enrollment Management 208 - - Search for class 209 - - Add student by email 210 - - Remove enrolled students 211 - - View enrollment list per class 212 - - Future: Bulk CSV import 213 - 214 - ### Recording Selection 215 - - View pending recordings per class 216 - - Select recording to transcribe for each meeting 217 - - View transcription status 218 - - Handle failed transcriptions 219 - 220 - ## Empty States 221 - 222 - - **No classes**: Show only register card with message "No classes yet" 223 - - **No recordings in class**: Show message "No recordings yet" with upload button 224 - - **No pending recordings**: Show message in admin "All recordings processed" 225 - 226 - ## Future Enhancements (Out of Scope) 227 - 228 - - Share/enrollment links for self-enrollment 229 - - Notifications when transcripts ready 230 - - Auto-transcribe settings per class 231 - - Student/instructor roles 232 - - Search/filter classes 233 - - Bulk enrollment via CSV 234 - - Meeting time templates (MWF, TTh patterns) 235 - - Download all transcripts for a class 236 - 237 - ## Open Questions 238 - 239 - None - spec is complete for initial implementation. 240 - 241 - ## Implementation Phases 242 - 243 - ### Phase 1: Database & Backend 244 - 1. Consolidate migrations and add new schema 245 - 2. Add API endpoints for classes and members 246 - 3. Update permissions middleware 247 - 4. Add admin endpoints 248 - 249 - ### Phase 2: Admin UI 250 - 1. Class management interface 251 - 2. Enrollment management 252 - 3. Recording selection interface 253 - 254 - ### Phase 3: Student UI 255 - 1. Classes page with cards 256 - 2. Individual class pages 257 - 3. Recording upload 258 - 4. Update navigation 259 - 260 - ### Phase 4: Testing & Polish 261 - 1. Test permissions thoroughly 262 - 2. Test archive behavior 263 - 3. Empty states 264 - 4. Error handling
+399
index.html
··· 1 + <!-- vim: setlocal noexpandtab nowrap: --> 2 + <!doctype html> 3 + <html lang="en"> 4 + 5 + <head> 6 + <meta charset="UTF-8" /> 7 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 + <title>C:\KIERANK.EXE - kierank.hackclub.app</title> 9 + <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> 10 + <style> 11 + @import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap"); 12 + 13 + body { 14 + background: #008080; 15 + font-family: "Courier Prime", "Courier New", monospace; 16 + color: #000000; 17 + margin: 0; 18 + padding: 20px; 19 + line-height: 1.4; 20 + font-size: 14px; 21 + } 22 + 23 + .hidden { 24 + display: none !important; 25 + } 26 + 27 + .terminal { 28 + background: #ffffff; 29 + border: 2px solid #808080; 30 + box-shadow: 31 + inset -2px -2px #c0c0c0, 32 + inset 2px 2px #404040, 33 + 4px 4px 0px #000000; 34 + max-width: 750px; 35 + margin: 0 auto; 36 + padding: 0; 37 + } 38 + 39 + .title-bar { 40 + background: linear-gradient(90deg, #000080, #1084d0); 41 + color: #ffffff; 42 + padding: 4px 8px; 43 + font-weight: bold; 44 + border-bottom: 1px solid #808080; 45 + font-size: 12px; 46 + display: flex; 47 + justify-content: space-between; 48 + align-items: center; 49 + } 50 + 51 + .title-buttons { 52 + display: flex; 53 + gap: 2px; 54 + } 55 + 56 + .title-btn { 57 + width: 16px; 58 + height: 14px; 59 + background: #c0c0c0; 60 + border: 1px outset #ffffff; 61 + font-size: 10px; 62 + line-height: 12px; 63 + text-align: center; 64 + cursor: pointer; 65 + } 66 + 67 + .content { 68 + padding: 15px; 69 + background: #ffffff; 70 + } 71 + 72 + .prompt { 73 + color: #000000; 74 + margin: 0 0 10px 0; 75 + } 76 + 77 + .cursor { 78 + background: #000000; 79 + color: #ffffff; 80 + animation: blink 1s infinite; 81 + } 82 + 83 + @keyframes blink { 84 + 85 + 0%, 86 + 50% { 87 + opacity: 1; 88 + } 89 + 90 + 51%, 91 + 100% { 92 + opacity: 0; 93 + } 94 + } 95 + 96 + /* Markdown rendered content styles */ 97 + #readme-content h3 { 98 + color: #000080; 99 + font-size: 20px; 100 + margin: 15px 0 10px 0; 101 + border-bottom: 2px solid #c0c0c0; 102 + padding-bottom: 5px; 103 + } 104 + 105 + #readme-content h4 { 106 + color: #000000; 107 + font-size: 14px; 108 + margin: 20px 0 8px 0; 109 + text-transform: uppercase; 110 + background: #c0c0c0; 111 + padding: 4px 8px; 112 + display: inline-block; 113 + } 114 + 115 + #readme-content p { 116 + margin: 8px 0; 117 + } 118 + 119 + #readme-content ul { 120 + margin: 8px 0; 121 + padding-left: 20px; 122 + list-style: none; 123 + } 124 + 125 + #readme-content ul li { 126 + margin: 6px 0; 127 + position: relative; 128 + } 129 + 130 + #readme-content ul li::before { 131 + content: "► "; 132 + color: #000080; 133 + } 134 + 135 + #readme-content a { 136 + color: #000080; 137 + text-decoration: underline; 138 + } 139 + 140 + #readme-content a:hover { 141 + color: #0000ff; 142 + background: #ffffcc; 143 + } 144 + 145 + #readme-content code { 146 + background: #000080; 147 + color: #ffffff; 148 + padding: 1px 4px; 149 + font-family: "Courier Prime", "Courier New", monospace; 150 + } 151 + 152 + #readme-content pre { 153 + background: #000080; 154 + color: #cbcbcb; 155 + padding: 12px; 156 + border: 2px inset #404040; 157 + overflow-x: auto; 158 + font-size: 12px; 159 + line-height: 1.3; 160 + } 161 + 162 + #readme-content pre code { 163 + background: transparent; 164 + color: inherit; 165 + padding: 0; 166 + } 167 + 168 + #readme-content strong { 169 + color: #333333; 170 + } 171 + 172 + #readme-content em { 173 + color: #808080; 174 + font-style: italic; 175 + } 176 + 177 + .status-bar { 178 + background: #c0c0c0; 179 + color: #000000; 180 + padding: 3px 8px; 181 + border-top: 2px groove #ffffff; 182 + font-size: 11px; 183 + display: flex; 184 + justify-content: space-between; 185 + } 186 + 187 + .separator { 188 + color: #808080; 189 + margin: 15px 0; 190 + text-align: center; 191 + } 192 + 193 + .loading { 194 + text-align: center; 195 + padding: 20px; 196 + color: #808080; 197 + } 198 + 199 + .loading::after { 200 + content: ""; 201 + animation: dots 1.5s steps(4, end) infinite; 202 + } 203 + 204 + @keyframes dots { 205 + 206 + 0%, 207 + 20% { 208 + content: ""; 209 + } 210 + 211 + 40% { 212 + content: "."; 213 + } 214 + 215 + 60% { 216 + content: ".."; 217 + } 218 + 219 + 80%, 220 + 100% { 221 + content: "..."; 222 + } 223 + } 224 + 225 + .error-box { 226 + background: #ff0000; 227 + color: #ffffff; 228 + padding: 10px; 229 + border: 2px inset #800000; 230 + margin: 10px 0; 231 + } 232 + 233 + .ascii-header { 234 + color: #000080; 235 + font-size: 10px; 236 + line-height: 1.1; 237 + white-space: pre; 238 + margin: 10px 0 15px 0; 239 + text-align: center; 240 + } 241 + </style> 242 + </head> 243 + 244 + <body> 245 + <div class="terminal"> 246 + <div class="title-bar"> 247 + <span>C:\KIERANK.EXE - kierank.hackclub.app</span> 248 + <div class="title-buttons"> 249 + <div class="title-btn">_</div> 250 + <div class="title-btn">□</div> 251 + <div class="title-btn">×</div> 252 + </div> 253 + </div> 254 + 255 + <div class="content"> 256 + <p class="prompt"> 257 + C:\KIERANK> README.EXE<span class="cursor"> </span> 258 + </p> 259 + 260 + <!-- biome-ignore format: ascii art --> 261 + <pre class="ascii-header"> 262 + ██╗ ██╗██╗███████╗██████╗ █████╗ ███╗ ██╗ 263 + ██║ ██║██║██╔════╝██╔══██╗██╔══██╗████╗ ██║ 264 + █████╔╝██║█████╗ ██████╔╝███████║██╔██╗ ██║ 265 + ██╔═██╗██║██╔══╝ ██╔══██╗██╔══██║██║╚██╗██║ 266 + ██║ ██║██║███████╗██║ ██║██║ ██║██║ ╚████║ 267 + ╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ 268 + </pre> 269 + 270 + <div class="separator"> 271 + ════════════════════════════════════════════════════ 272 + </div> 273 + 274 + <div id="readme-content"> 275 + <div class="loading" style="display: none">Loading README from GitHub</div> 276 + 277 + <noscript> 278 + <div class="error-box"> 279 + JavaScript is disabled so not rendering markdown. 280 + </div> 281 + <p> 282 + View the README on GitHub: 283 + <a href="https://github.com/taciturnaxolotl/taciturnaxolotl/blob/main/README.md"> 284 + taciturnaxolotl/README.md 285 + </a> 286 + </p> 287 + </noscript> 288 + </div> 289 + 290 + <div class="separator"> 291 + ════════════════════════════════════════════════════ 292 + </div> 293 + 294 + <p style="font-size: 11px; color: #808080; text-align: center"> 295 + Hosted on <a href="https://hackclub.com/nest/">Hack Club Nest</a> | 296 + <a href="https://github.com/taciturnaxolotl">GitHub</a> | 297 + <a href="https://dunkirk.sh">dunkirk.sh</a> 298 + </p> 299 + </div> 300 + 301 + <div class="status-bar"> 302 + <span id="status">Fetching README...</span> 303 + <span id="time"></span> 304 + </div> 305 + </div> 306 + 307 + <script> 308 + const README_URL = 309 + "https://raw.githubusercontent.com/taciturnaxolotl/taciturnaxolotl/refs/heads/main/README.md" 310 + 311 + async function loadReadme() { 312 + const contentDiv = document.getElementById("readme-content"); 313 + const statusSpan = document.getElementById("status"); 314 + const loadingEl = contentDiv.querySelector(".loading"); 315 + 316 + // Show loading 317 + if (loadingEl) loadingEl.classList.remove("hidden"); 318 + 319 + 320 + try { 321 + const response = await fetch(README_URL); 322 + if (!response.ok) { 323 + throw new Error(`HTTP ${response.status}`); 324 + } 325 + 326 + const markdown = await response.text(); 327 + 328 + // Configure marked for GFM 329 + marked.setOptions({ 330 + gfm: true, 331 + breaks: true, 332 + }); 333 + 334 + contentDiv.innerHTML = marked.parse(markdown); 335 + 336 + // Strip emojis from headers 337 + contentDiv.querySelectorAll("h3, h4").forEach((header) => { 338 + const text = header.textContent; 339 + 340 + // Regex targets: 341 + // - \p{Extended_Pictographic}: most modern emoji/pictographs 342 + // - \p{Emoji_Presentation}: characters default-rendered as emoji 343 + // - Variation Selector-16 (U+FE0F): emoji presentation modifier 344 + // - Common symbol blocks that often carry emoji-like glyphs 345 + const emojiRegex = 346 + /[\p{Extended_Pictographic}\p{Emoji_Presentation}\uFE0F]|[\u2600-\u26FF\u2700-\u27BF]/gu; 347 + 348 + // Strip emojis 349 + let cleaned = text.replace(emojiRegex, ""); 350 + 351 + // Collapse multiple whitespace into single spaces 352 + cleaned = cleaned.replace(/\s+/g, " "); 353 + 354 + // Remove spaces before punctuation (e.g., "Hello !" -> "Hello!") 355 + cleaned = cleaned.replace(/\s+([!?,.;:])/g, "$1"); 356 + 357 + // Trim leading/trailing whitespace 358 + cleaned = cleaned.trim(); 359 + 360 + // Replace header content safely 361 + header.textContent = cleaned; 362 + }); 363 + 364 + statusSpan.textContent = "README loaded successfully"; 365 + } catch (error) { 366 + contentDiv.innerHTML = ` 367 + <div class="error-box"> 368 + ERROR: Failed to load README<br> 369 + ${error.message} 370 + </div> 371 + <p>Try refreshing the page or visit 372 + <a href="https://github.com/taciturnaxolotl">github.com/taciturnaxolotl</a> directly.</p> 373 + `; 374 + statusSpan.textContent = "Error loading README"; 375 + } 376 + } 377 + 378 + function updateTime() { 379 + const now = new Date(); 380 + const timeStr = now.toLocaleTimeString("en-US", { 381 + hour: "2-digit", 382 + minute: "2-digit", 383 + hour12: true, 384 + }); 385 + document.getElementById("time").textContent = timeStr; 386 + } 387 + 388 + // Initialize 389 + updateTime(); 390 + setInterval(updateTime, 1000); 391 + loadReadme(); 392 + </script> 393 + </body> 394 + 395 + </html> 396 + 397 + </html> 398 + 399 + </html>
+70
scripts/send-test-emails.ts
··· 1 + /** 2 + * Send test emails to preview all email templates 3 + * Usage: bun scripts/send-test-emails.ts <email> 4 + */ 5 + 6 + import { sendEmail } from "../src/lib/email"; 7 + import { 8 + verifyEmailTemplate, 9 + passwordResetTemplate, 10 + transcriptionCompleteTemplate, 11 + } from "../src/lib/email-templates"; 12 + 13 + const targetEmail = process.argv[2]; 14 + 15 + if (!targetEmail) { 16 + console.error("Usage: bun scripts/send-test-emails.ts <email>"); 17 + process.exit(1); 18 + } 19 + 20 + async function sendTestEmails() { 21 + console.log(`Sending test emails to ${targetEmail}...`); 22 + 23 + try { 24 + // 1. Email verification 25 + console.log("\n[1/3] Sending email verification..."); 26 + await sendEmail({ 27 + to: targetEmail, 28 + subject: "Test: Verify your email - Thistle", 29 + html: verifyEmailTemplate({ 30 + name: "Test User", 31 + code: "123456", 32 + token: "test-token-abc123", 33 + }), 34 + }); 35 + console.log("✓ Email verification sent"); 36 + 37 + // 2. Password reset 38 + console.log("\n[2/3] Sending password reset..."); 39 + await sendEmail({ 40 + to: targetEmail, 41 + subject: "Test: Reset your password - Thistle", 42 + html: passwordResetTemplate({ 43 + name: "Test User", 44 + resetLink: "https://thistle.app/reset-password?token=test-token-xyz789", 45 + }), 46 + }); 47 + console.log("✓ Password reset sent"); 48 + 49 + // 3. Transcription complete 50 + console.log("\n[3/3] Sending transcription complete..."); 51 + await sendEmail({ 52 + to: targetEmail, 53 + subject: "Test: Transcription complete - Thistle", 54 + html: transcriptionCompleteTemplate({ 55 + name: "Test User", 56 + originalFilename: "lecture-2024-11-22.m4a", 57 + transcriptLink: "https://thistle.app/transcriptions/123", 58 + className: "Introduction to Computer Science", 59 + }), 60 + }); 61 + console.log("✓ Transcription complete sent"); 62 + 63 + console.log("\n✅ All test emails sent successfully!"); 64 + } catch (error) { 65 + console.error("\n❌ Error sending emails:", error); 66 + process.exit(1); 67 + } 68 + } 69 + 70 + sendTestEmails();
+144 -4
src/components/auth.ts
··· 30 30 @state() needsRegistration = false; 31 31 @state() passwordStrength: PasswordStrengthResult | null = null; 32 32 @state() passkeySupported = false; 33 + @state() needsEmailVerification = false; 34 + @state() verificationCode = ""; 33 35 34 36 static override styles = css` 35 37 :host { ··· 268 270 .info-text { 269 271 color: var(--text); 270 272 font-size: 0.875rem; 271 - margin: 0; 273 + margin: 0 0 1.5rem 0; 274 + line-height: 1.5; 275 + } 276 + 277 + .verification-code-input { 278 + text-align: center; 279 + font-size: 1.5rem; 280 + letter-spacing: 0.5rem; 281 + font-weight: 600; 282 + padding: 1rem; 283 + font-family: 'Monaco', 'Courier New', monospace; 284 + } 285 + 286 + .btn-secondary { 287 + background: transparent; 288 + color: var(--text); 289 + border-color: var(--secondary); 290 + flex: 1; 291 + } 292 + 293 + .btn-secondary:hover:not(:disabled) { 294 + border-color: var(--primary); 295 + color: var(--primary); 272 296 } 273 297 274 298 .divider { ··· 383 407 return; 384 408 } 385 409 386 - this.user = await response.json(); 410 + const data = await response.json(); 411 + 412 + if (data.email_verification_required) { 413 + this.needsEmailVerification = true; 414 + this.password = ""; 415 + this.error = ""; 416 + return; 417 + } 418 + 419 + this.user = data; 387 420 this.closeModal(); 388 421 await this.checkAuth(); 389 422 window.dispatchEvent(new CustomEvent("auth-changed")); ··· 411 444 return; 412 445 } 413 446 414 - this.user = await response.json(); 447 + const data = await response.json(); 448 + 449 + if (data.email_verification_required) { 450 + this.needsEmailVerification = true; 451 + this.password = ""; 452 + this.error = ""; 453 + return; 454 + } 455 + 456 + this.user = data; 415 457 this.closeModal(); 416 458 await this.checkAuth(); 417 459 window.dispatchEvent(new CustomEvent("auth-changed")); ··· 453 495 this.password = (e.target as HTMLInputElement).value; 454 496 } 455 497 498 + private handleVerificationCodeInput(e: Event) { 499 + this.verificationCode = (e.target as HTMLInputElement).value; 500 + } 501 + 502 + private async handleVerifyEmail(e: Event) { 503 + e.preventDefault(); 504 + this.error = ""; 505 + this.isSubmitting = true; 506 + 507 + try { 508 + const response = await fetch("/api/auth/verify-email", { 509 + method: "POST", 510 + headers: { 511 + "Content-Type": "application/json", 512 + }, 513 + body: JSON.stringify({ 514 + email: this.email, 515 + code: this.verificationCode, 516 + }), 517 + }); 518 + 519 + if (!response.ok) { 520 + const data = await response.json(); 521 + this.error = data.error || "Verification failed"; 522 + return; 523 + } 524 + 525 + // Successfully verified - redirect to classes 526 + this.closeModal(); 527 + await this.checkAuth(); 528 + window.dispatchEvent(new CustomEvent("auth-changed")); 529 + window.location.href = "/classes"; 530 + } catch (error) { 531 + this.error = error instanceof Error ? error.message : "An error occurred"; 532 + } finally { 533 + this.isSubmitting = false; 534 + } 535 + } 536 + 456 537 private handlePasswordBlur() { 457 538 if (!this.needsRegistration) return; 458 539 ··· 543 624 <div class="modal-overlay" @click=${this.closeModal}> 544 625 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 545 626 <h2 class="modal-title"> 546 - ${this.needsRegistration ? "Create Account" : "Sign In"} 627 + ${this.needsEmailVerification ? "Verify Email" : this.needsRegistration ? "Create Account" : "Sign In"} 547 628 </h2> 548 629 549 630 ${ 631 + this.needsEmailVerification 632 + ? html` 633 + <p class="info-text"> 634 + We sent a 6-digit verification code to <strong>${this.email}</strong>.<br> 635 + Check your email and enter the code below. 636 + </p> 637 + 638 + <form @submit=${this.handleVerifyEmail}> 639 + <div class="form-group"> 640 + <label for="verification-code">Verification Code</label> 641 + <input 642 + type="text" 643 + id="verification-code" 644 + class="verification-code-input" 645 + placeholder="000000" 646 + .value=${this.verificationCode} 647 + @input=${this.handleVerificationCodeInput} 648 + required 649 + maxlength="6" 650 + pattern="[0-9]{6}" 651 + inputmode="numeric" 652 + ?disabled=${this.isSubmitting} 653 + autocomplete="one-time-code" 654 + /> 655 + </div> 656 + 657 + ${ 658 + this.error 659 + ? html`<div class="error-message">${this.error}</div>` 660 + : "" 661 + } 662 + 663 + <div class="modal-actions"> 664 + <button 665 + type="submit" 666 + class="btn-primary" 667 + ?disabled=${this.isSubmitting || this.verificationCode.length !== 6} 668 + > 669 + ${this.isSubmitting ? "Verifying..." : "Verify Email"} 670 + </button> 671 + <button 672 + type="button" 673 + class="btn-secondary" 674 + @click=${() => { 675 + this.needsEmailVerification = false; 676 + this.verificationCode = ""; 677 + this.error = ""; 678 + }} 679 + ?disabled=${this.isSubmitting} 680 + > 681 + Back 682 + </button> 683 + </div> 684 + </form> 685 + ` 686 + : html` 687 + ${ 550 688 this.needsRegistration 551 689 ? html` 552 690 <p class="info-text"> ··· 660 798 </button> 661 799 </div> 662 800 </form> 801 + ` 802 + } 663 803 </div> 664 804 </div> 665 805 `
+34
src/db/schema.ts
··· 213 213 VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now')); 214 214 `, 215 215 }, 216 + { 217 + version: 8, 218 + name: "Add email verification system", 219 + sql: ` 220 + -- Add email verification flag to users 221 + ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0; 222 + 223 + -- Email verification tokens table 224 + CREATE TABLE IF NOT EXISTS email_verification_tokens ( 225 + id TEXT PRIMARY KEY, 226 + user_id INTEGER NOT NULL, 227 + token TEXT NOT NULL UNIQUE, 228 + expires_at INTEGER NOT NULL, 229 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 230 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 231 + ); 232 + 233 + CREATE INDEX IF NOT EXISTS idx_verification_tokens_user_id ON email_verification_tokens(user_id); 234 + CREATE INDEX IF NOT EXISTS idx_verification_tokens_token ON email_verification_tokens(token); 235 + 236 + -- Password reset tokens table 237 + CREATE TABLE IF NOT EXISTS password_reset_tokens ( 238 + id TEXT PRIMARY KEY, 239 + user_id INTEGER NOT NULL, 240 + token TEXT NOT NULL UNIQUE, 241 + expires_at INTEGER NOT NULL, 242 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 243 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 244 + ); 245 + 246 + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); 247 + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token); 248 + `, 249 + }, 216 250 ]; 217 251 218 252 function getCurrentVersion(): number {
+299 -12
src/index.ts
··· 24 24 updateUserName, 25 25 updateUserPassword, 26 26 updateUserRole, 27 + createEmailVerificationToken, 28 + verifyEmailToken, 29 + verifyEmailCode, 30 + isEmailVerified, 31 + createPasswordResetToken, 32 + verifyPasswordResetToken, 33 + consumePasswordResetToken, 27 34 } from "./lib/auth"; 28 35 import { 29 36 addToWaitlist, ··· 62 69 verifyAndAuthenticatePasskey, 63 70 verifyAndCreatePasskey, 64 71 } from "./lib/passkey"; 65 - import { enforceRateLimit } from "./lib/rate-limit"; 72 + import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit"; 66 73 import { getTranscriptVTT } from "./lib/transcript-storage"; 67 74 import { 68 75 MAX_FILE_SIZE, ··· 70 77 type TranscriptionUpdate, 71 78 WhisperServiceManager, 72 79 } from "./lib/transcription"; 80 + import { sendEmail } from "./lib/email"; 81 + import { 82 + verifyEmailTemplate, 83 + passwordResetTemplate, 84 + } from "./lib/email-templates"; 73 85 import adminHTML from "./pages/admin.html"; 74 86 import checkoutHTML from "./pages/checkout.html"; 75 87 import classHTML from "./pages/class.html"; 76 88 import classesHTML from "./pages/classes.html"; 77 89 import indexHTML from "./pages/index.html"; 90 + import resetPasswordHTML from "./pages/reset-password.html"; 78 91 import settingsHTML from "./pages/settings.html"; 79 92 import transcribeHTML from "./pages/transcribe.html"; 80 93 ··· 218 231 "/admin": adminHTML, 219 232 "/checkout": checkoutHTML, 220 233 "/settings": settingsHTML, 234 + "/reset-password": resetPasswordHTML, 221 235 "/transcribe": transcribeHTML, 222 236 "/classes": classesHTML, 223 237 "/classes/*": classHTML, ··· 231 245 try { 232 246 // Rate limiting 233 247 const rateLimitError = enforceRateLimit(req, "register", { 234 - ip: { max: 5, windowSeconds: 60 * 60 }, 248 + ip: { max: 5, windowSeconds: 30 * 60 }, 235 249 }); 236 250 if (rateLimitError) return rateLimitError; 237 251 ··· 252 266 } 253 267 const user = await createUser(email, password, name); 254 268 255 - // Attempt to sync existing Polar subscriptions 269 + // Send verification email - MUST succeed for registration to complete 270 + const { code, token } = createEmailVerificationToken(user.id); 271 + 272 + try { 273 + await sendEmail({ 274 + to: user.email, 275 + subject: "Verify your email - Thistle", 276 + html: verifyEmailTemplate({ 277 + name: user.name, 278 + code, 279 + token, 280 + }), 281 + }); 282 + } catch (err) { 283 + console.error("[Email] Failed to send verification email:", err); 284 + // Rollback user creation - direct DB delete since user was just created 285 + db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]); 286 + db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]); 287 + db.run("DELETE FROM users WHERE id = ?", [user.id]); 288 + return Response.json( 289 + { error: "Failed to send verification email. Please try again later." }, 290 + { status: 500 }, 291 + ); 292 + } 293 + 294 + // Attempt to sync existing Polar subscriptions (after email succeeds) 256 295 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => { 257 296 // Silent fail - don't block registration 258 297 }); 259 298 299 + // Clear rate limits on successful registration 260 300 const ipAddress = 261 301 req.headers.get("x-forwarded-for") ?? 262 302 req.headers.get("x-real-ip") ?? 263 303 "unknown"; 264 - const userAgent = req.headers.get("user-agent") ?? "unknown"; 265 - const sessionId = createSession(user.id, ipAddress, userAgent); 304 + clearRateLimit("register", email, ipAddress); 305 + 306 + // Return success but indicate email verification is needed 307 + // Don't create session yet - they need to verify first 266 308 return Response.json( 267 - { user: { id: user.id, email: user.email } }, 268 - { 269 - headers: { 270 - "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 271 - }, 309 + { 310 + user: { id: user.id, email: user.email }, 311 + email_verification_required: true, 272 312 }, 313 + { status: 200 }, 273 314 ); 274 315 } catch (err: unknown) { 275 316 const error = err as { message?: string }; ··· 300 341 301 342 // Rate limiting: Per IP and per account 302 343 const rateLimitError = enforceRateLimit(req, "login", { 303 - ip: { max: 10, windowSeconds: 15 * 60 }, 304 - account: { max: 5, windowSeconds: 15 * 60, email }, 344 + ip: { max: 10, windowSeconds: 5 * 60 }, 345 + account: { max: 5, windowSeconds: 5 * 60, email }, 305 346 }); 306 347 if (rateLimitError) return rateLimitError; 307 348 ··· 319 360 { status: 401 }, 320 361 ); 321 362 } 363 + 364 + // Clear rate limits on successful authentication 322 365 const ipAddress = 323 366 req.headers.get("x-forwarded-for") ?? 324 367 req.headers.get("x-real-ip") ?? 325 368 "unknown"; 369 + clearRateLimit("login", email, ipAddress); 370 + 371 + // Check if email is verified 372 + if (!isEmailVerified(user.id)) { 373 + return Response.json( 374 + { 375 + user: { id: user.id, email: user.email }, 376 + email_verification_required: true, 377 + }, 378 + { status: 200 }, 379 + ); 380 + } 381 + 326 382 const userAgent = req.headers.get("user-agent") ?? "unknown"; 327 383 const sessionId = createSession(user.id, ipAddress, userAgent); 328 384 return Response.json( ··· 338 394 } 339 395 }, 340 396 }, 397 + "/api/auth/verify-email": { 398 + GET: async (req) => { 399 + try { 400 + const url = new URL(req.url); 401 + const token = url.searchParams.get("token"); 402 + 403 + if (!token) { 404 + return Response.redirect("/", 302); 405 + } 406 + 407 + const result = verifyEmailToken(token); 408 + 409 + if (!result) { 410 + return Response.redirect("/", 302); 411 + } 412 + 413 + // Create session for the verified user 414 + const ipAddress = 415 + req.headers.get("x-forwarded-for") ?? 416 + req.headers.get("x-real-ip") ?? 417 + "unknown"; 418 + const userAgent = req.headers.get("user-agent") ?? "unknown"; 419 + const sessionId = createSession(result.userId, ipAddress, userAgent); 420 + 421 + // Redirect to classes with session cookie 422 + return new Response(null, { 423 + status: 302, 424 + headers: { 425 + "Location": "/classes", 426 + "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 427 + }, 428 + }); 429 + } catch (error) { 430 + console.error("[Email] Verification error:", error); 431 + return Response.redirect("/", 302); 432 + } 433 + }, 434 + POST: async (req) => { 435 + try { 436 + const body = await req.json(); 437 + const { email, code } = body; 438 + 439 + if (!email || !code) { 440 + return Response.json( 441 + { error: "Email and verification code required" }, 442 + { status: 400 }, 443 + ); 444 + } 445 + 446 + // Get user by email 447 + const user = getUserByEmail(email); 448 + if (!user) { 449 + return Response.json( 450 + { error: "User not found" }, 451 + { status: 404 }, 452 + ); 453 + } 454 + 455 + // Check if already verified 456 + if (isEmailVerified(user.id)) { 457 + return Response.json( 458 + { error: "Email already verified" }, 459 + { status: 400 }, 460 + ); 461 + } 462 + 463 + const success = verifyEmailCode(user.id, code); 464 + 465 + if (!success) { 466 + return Response.json( 467 + { error: "Invalid or expired verification code" }, 468 + { status: 400 }, 469 + ); 470 + } 471 + 472 + // Create session after successful verification 473 + const ipAddress = 474 + req.headers.get("x-forwarded-for") ?? 475 + req.headers.get("x-real-ip") ?? 476 + "unknown"; 477 + const userAgent = req.headers.get("user-agent") ?? "unknown"; 478 + const sessionId = createSession(user.id, ipAddress, userAgent); 479 + 480 + return Response.json( 481 + { 482 + message: "Email verified successfully", 483 + email_verified: true, 484 + user: { id: user.id, email: user.email }, 485 + }, 486 + { 487 + headers: { 488 + "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 489 + }, 490 + }, 491 + ); 492 + } catch (error) { 493 + return handleError(error); 494 + } 495 + }, 496 + }, 497 + "/api/auth/resend-verification": { 498 + POST: async (req) => { 499 + try { 500 + const user = requireAuth(req); 501 + 502 + // Rate limiting 503 + const rateLimitError = enforceRateLimit(req, "resend-verification", { 504 + account: { max: 3, windowSeconds: 60 * 60, email: user.email }, 505 + }); 506 + if (rateLimitError) return rateLimitError; 507 + 508 + // Check if already verified 509 + if (isEmailVerified(user.id)) { 510 + return Response.json( 511 + { error: "Email already verified" }, 512 + { status: 400 }, 513 + ); 514 + } 515 + 516 + // Generate new code and send email 517 + const { code, token } = createEmailVerificationToken(user.id); 518 + 519 + await sendEmail({ 520 + to: user.email, 521 + subject: "Verify your email - Thistle", 522 + html: verifyEmailTemplate({ 523 + name: user.name, 524 + code, 525 + token, 526 + }), 527 + }); 528 + 529 + return Response.json({ message: "Verification email sent" }); 530 + } catch (error) { 531 + return handleError(error); 532 + } 533 + }, 534 + }, 535 + "/api/auth/forgot-password": { 536 + POST: async (req) => { 537 + try { 538 + // Rate limiting 539 + const rateLimitError = enforceRateLimit(req, "forgot-password", { 540 + ip: { max: 5, windowSeconds: 60 * 60 }, 541 + }); 542 + if (rateLimitError) return rateLimitError; 543 + 544 + const body = await req.json(); 545 + const { email } = body; 546 + 547 + if (!email) { 548 + return Response.json({ error: "Email required" }, { status: 400 }); 549 + } 550 + 551 + // Always return success to prevent email enumeration 552 + const user = getUserByEmail(email); 553 + if (user) { 554 + const origin = 555 + req.headers.get("origin") || "http://localhost:3000"; 556 + const resetToken = createPasswordResetToken(user.id); 557 + const resetLink = `${origin}/reset-password?token=${resetToken}`; 558 + 559 + await sendEmail({ 560 + to: user.email, 561 + subject: "Reset your password - Thistle", 562 + html: passwordResetTemplate({ 563 + name: user.name, 564 + resetLink, 565 + }), 566 + }).catch((err) => { 567 + console.error("[Email] Failed to send password reset:", err); 568 + }); 569 + } 570 + 571 + return Response.json({ 572 + message: 573 + "If an account exists with that email, a password reset link has been sent", 574 + }); 575 + } catch (error) { 576 + console.error("[Email] Forgot password error:", error); 577 + return Response.json( 578 + { error: "Failed to process request" }, 579 + { status: 500 }, 580 + ); 581 + } 582 + }, 583 + }, 584 + "/api/auth/reset-password": { 585 + POST: async (req) => { 586 + try { 587 + const body = await req.json(); 588 + const { token, password } = body; 589 + 590 + if (!token || !password) { 591 + return Response.json( 592 + { error: "Token and password required" }, 593 + { status: 400 }, 594 + ); 595 + } 596 + 597 + // Validate password format (client-side hashed PBKDF2) 598 + if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 599 + return Response.json( 600 + { error: "Invalid password format" }, 601 + { status: 400 }, 602 + ); 603 + } 604 + 605 + const userId = verifyPasswordResetToken(token); 606 + if (!userId) { 607 + return Response.json( 608 + { error: "Invalid or expired reset token" }, 609 + { status: 400 }, 610 + ); 611 + } 612 + 613 + // Update password and consume token 614 + await updateUserPassword(userId, password); 615 + consumePasswordResetToken(token); 616 + 617 + return Response.json({ message: "Password reset successfully" }); 618 + } catch (error) { 619 + console.error("[Email] Reset password error:", error); 620 + return Response.json( 621 + { error: "Failed to reset password" }, 622 + { status: 500 }, 623 + ); 624 + } 625 + }, 626 + }, 341 627 "/api/auth/logout": { 342 628 POST: async (req) => { 343 629 const sessionId = getSessionFromRequest(req); ··· 380 666 created_at: user.created_at, 381 667 role: user.role, 382 668 has_subscription: !!subscription, 669 + email_verified: isEmailVerified(user.id), 383 670 }); 384 671 }, 385 672 },
+131
src/lib/auth.ts
··· 253 253 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 254 254 } 255 255 256 + /** 257 + * Email verification functions 258 + */ 259 + 260 + export function createEmailVerificationToken(userId: number): { code: string; token: string } { 261 + // Generate a 6-digit code for user to enter 262 + const code = Math.floor(100000 + Math.random() * 900000).toString(); 263 + const id = crypto.randomUUID(); 264 + const token = crypto.randomUUID(); // Separate token for URL 265 + const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours 266 + 267 + // Delete any existing tokens for this user 268 + db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]); 269 + 270 + // Store the code as the token field (for manual entry) 271 + db.run( 272 + "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)", 273 + [id, userId, code, expiresAt], 274 + ); 275 + 276 + // Store the URL token as a separate entry 277 + db.run( 278 + "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)", 279 + [crypto.randomUUID(), userId, token, expiresAt], 280 + ); 281 + 282 + return { code, token }; 283 + } 284 + 285 + export function verifyEmailToken( 286 + token: string, 287 + ): { userId: number; email: string } | null { 288 + const now = Math.floor(Date.now() / 1000); 289 + 290 + const result = db 291 + .query< 292 + { user_id: number; email: string }, 293 + [string, number] 294 + >( 295 + `SELECT evt.user_id, u.email 296 + FROM email_verification_tokens evt 297 + JOIN users u ON evt.user_id = u.id 298 + WHERE evt.token = ? AND evt.expires_at > ?`, 299 + ) 300 + .get(token, now); 301 + 302 + if (!result) return null; 303 + 304 + // Mark email as verified 305 + db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [result.user_id]); 306 + 307 + // Delete the token (one-time use) 308 + db.run("DELETE FROM email_verification_tokens WHERE token = ?", [token]); 309 + 310 + return { userId: result.user_id, email: result.email }; 311 + } 312 + 313 + export function verifyEmailCode( 314 + userId: number, 315 + code: string, 316 + ): boolean { 317 + const now = Math.floor(Date.now() / 1000); 318 + 319 + const result = db 320 + .query< 321 + { user_id: number }, 322 + [number, string, number] 323 + >( 324 + `SELECT user_id 325 + FROM email_verification_tokens 326 + WHERE user_id = ? AND token = ? AND expires_at > ?`, 327 + ) 328 + .get(userId, code, now); 329 + 330 + if (!result) return false; 331 + 332 + // Mark email as verified 333 + db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]); 334 + 335 + // Delete the token (one-time use) 336 + db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]); 337 + 338 + return true; 339 + } 340 + 341 + export function isEmailVerified(userId: number): boolean { 342 + const result = db 343 + .query<{ email_verified: number }, [number]>( 344 + "SELECT email_verified FROM users WHERE id = ?", 345 + ) 346 + .get(userId); 347 + 348 + return result?.email_verified === 1; 349 + } 350 + 351 + /** 352 + * Password reset functions 353 + */ 354 + 355 + export function createPasswordResetToken(userId: number): string { 356 + const token = crypto.randomUUID(); 357 + const id = crypto.randomUUID(); 358 + const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour 359 + 360 + // Delete any existing tokens for this user 361 + db.run("DELETE FROM password_reset_tokens WHERE user_id = ?", [userId]); 362 + 363 + db.run( 364 + "INSERT INTO password_reset_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)", 365 + [id, userId, token, expiresAt], 366 + ); 367 + 368 + return token; 369 + } 370 + 371 + export function verifyPasswordResetToken(token: string): number | null { 372 + const now = Math.floor(Date.now() / 1000); 373 + 374 + const result = db 375 + .query<{ user_id: number }, [string, number]>( 376 + "SELECT user_id FROM password_reset_tokens WHERE token = ? AND expires_at > ?", 377 + ) 378 + .get(token, now); 379 + 380 + return result?.user_id ?? null; 381 + } 382 + 383 + export function consumePasswordResetToken(token: string): void { 384 + db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]); 385 + } 386 + 256 387 export function isUserAdmin(userId: number): boolean { 257 388 const result = db 258 389 .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
+233
src/lib/email-templates.ts
··· 1 + /** 2 + * Email templates for transactional emails 3 + * Uses inline CSS for maximum email client compatibility 4 + */ 5 + 6 + const baseStyles = ` 7 + body { 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; 9 + line-height: 1.6; 10 + color: #2d3142; 11 + background-color: #ffffff; 12 + margin: 0; 13 + padding: 0; 14 + } 15 + .container { 16 + max-width: 600px; 17 + margin: 0 auto; 18 + padding: 2rem 1rem; 19 + } 20 + .header { 21 + text-align: center; 22 + margin-bottom: 2rem; 23 + } 24 + .header h1 { 25 + color: #2d3142; 26 + font-size: 1.5rem; 27 + margin: 0; 28 + } 29 + .content { 30 + background: #ffffff; 31 + padding: 2rem; 32 + border-radius: 0.5rem; 33 + border: 1px solid #bfc0c0; 34 + } 35 + .button { 36 + display: inline-block; 37 + background-color: #ef8354; 38 + color: #ffffff; 39 + text-decoration: none; 40 + padding: 0.75rem 1.5rem; 41 + border-radius: 6px; 42 + font-weight: 500; 43 + font-size: 1rem; 44 + margin: 1rem 0; 45 + border: 2px solid #ef8354; 46 + } 47 + .footer { 48 + text-align: center; 49 + margin-top: 2rem; 50 + color: #4f5d75; 51 + font-size: 0.875rem; 52 + } 53 + .code { 54 + background: #f5f5f5; 55 + border: 1px solid #bfc0c0; 56 + border-radius: 0.25rem; 57 + padding: 0.5rem 1rem; 58 + font-family: 'Courier New', monospace; 59 + font-size: 1.5rem; 60 + letter-spacing: 0.25rem; 61 + text-align: center; 62 + margin: 1rem 0; 63 + } 64 + .info-box { 65 + background: #f5f5f5; 66 + border: 1px solid #bfc0c0; 67 + border-radius: 6px; 68 + padding: 1.25rem; 69 + margin: 1rem 0; 70 + } 71 + .info-box-label { 72 + color: #4f5d75; 73 + font-size: 0.75rem; 74 + text-transform: uppercase; 75 + letter-spacing: 0.05rem; 76 + margin: 0 0 0.25rem 0; 77 + font-weight: 600; 78 + } 79 + .info-box-value { 80 + color: #2d3142; 81 + font-size: 1rem; 82 + margin: 0; 83 + } 84 + .info-box-divider { 85 + border: 0; 86 + border-top: 1px solid #bfc0c0; 87 + margin: 1rem 0; 88 + } 89 + `; 90 + 91 + interface VerifyEmailOptions { 92 + name: string | null; 93 + code: string; 94 + token: string; 95 + } 96 + 97 + export function verifyEmailTemplate(options: VerifyEmailOptions): string { 98 + const greeting = options.name ? `Hi ${options.name}` : "Hi there"; 99 + const domain = process.env.DOMAIN || "https://thistle.app"; 100 + const verifyLink = `${domain}/api/auth/verify-email?token=${options.token}`; 101 + 102 + return ` 103 + <!DOCTYPE html> 104 + <html lang="en"> 105 + <head> 106 + <meta charset="UTF-8"> 107 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 108 + <title>Verify Your Email - Thistle</title> 109 + <style>${baseStyles}</style> 110 + </head> 111 + <body> 112 + <div class="container"> 113 + <div class="header"> 114 + <h1>🪻 Thistle</h1> 115 + </div> 116 + <div class="content"> 117 + <h2>${greeting}!</h2> 118 + <p>Thanks for signing up for Thistle. Please verify your email address to get started.</p> 119 + <p><strong>Your verification code is:</strong></p> 120 + <div class="code">${options.code}</div> 121 + <p style="color: #4f5d75; font-size: 0.875rem; margin-top: 1rem; margin-bottom: 0.75rem;"> 122 + This code will expire in 24 hours. Enter it in the verification dialog after you login, or click the button below: 123 + </p> 124 + <p style="text-align: center; margin-top: 0; margin-bottom: 0.75rem;"> 125 + <a href="${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</a> 126 + </p> 127 + </div> 128 + <div class="footer"> 129 + <p>If you didn't create an account, you can safely ignore this email.</p> 130 + </div> 131 + </div> 132 + </body> 133 + </html> 134 + `.trim(); 135 + } 136 + 137 + interface PasswordResetOptions { 138 + name: string | null; 139 + resetLink: string; 140 + } 141 + 142 + export function passwordResetTemplate(options: PasswordResetOptions): string { 143 + const greeting = options.name ? `Hi ${options.name}` : "Hi there"; 144 + 145 + return ` 146 + <!DOCTYPE html> 147 + <html lang="en"> 148 + <head> 149 + <meta charset="UTF-8"> 150 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 151 + <title>Reset Your Password - Thistle</title> 152 + <style>${baseStyles}</style> 153 + </head> 154 + <body> 155 + <div class="container"> 156 + <div class="header"> 157 + <h1>🪻 Thistle</h1> 158 + </div> 159 + <div class="content"> 160 + <h2>${greeting}!</h2> 161 + <p>We received a request to reset your password. Click the button below to create a new password.</p> 162 + <p style="text-align: center;"> 163 + <a href="${options.resetLink}" 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: 1rem 0; border: 2px solid #ef8354;">Reset Password</a> 164 + </p> 165 + <p style="color: #4f5d75; font-size: 0.875rem;"> 166 + If the button doesn't work, copy and paste this link into your browser:<br> 167 + <a href="${options.resetLink}" style="color: #4f5d75; word-break: break-all;">${options.resetLink}</a> 168 + </p> 169 + <p style="color: #4f5d75; font-size: 0.875rem;"> 170 + This link will expire in 1 hour. 171 + </p> 172 + </div> 173 + <div class="footer"> 174 + <p>If you didn't request a password reset, you can safely ignore this email.</p> 175 + </div> 176 + </div> 177 + </body> 178 + </html> 179 + `.trim(); 180 + } 181 + 182 + interface TranscriptionCompleteOptions { 183 + name: string | null; 184 + originalFilename: string; 185 + transcriptLink: string; 186 + className?: string; 187 + } 188 + 189 + export function transcriptionCompleteTemplate( 190 + options: TranscriptionCompleteOptions, 191 + ): string { 192 + const greeting = options.name ? `Hi ${options.name}` : "Hi there"; 193 + 194 + return ` 195 + <!DOCTYPE html> 196 + <html lang="en"> 197 + <head> 198 + <meta charset="UTF-8"> 199 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 200 + <title>Transcription Complete - Thistle</title> 201 + <style>${baseStyles}</style> 202 + </head> 203 + <body> 204 + <div class="container"> 205 + <div class="header"> 206 + <h1>🪻 Thistle</h1> 207 + </div> 208 + <div class="content"> 209 + <h2>${greeting}!</h2> 210 + <p>Your transcription is ready!</p> 211 + 212 + <div class="info-box"> 213 + ${options.className ? ` 214 + <p class="info-box-label">Class</p> 215 + <p class="info-box-value">${options.className}</p> 216 + <hr class="info-box-divider"> 217 + ` : ''} 218 + <p class="info-box-label">File</p> 219 + <p class="info-box-value">${options.originalFilename}</p> 220 + </div> 221 + 222 + <p style="text-align: center; margin-top: 1.5rem; margin-bottom: 0;"> 223 + <a href="${options.transcriptLink}" 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;">View Transcript</a> 224 + </p> 225 + </div> 226 + <div class="footer"> 227 + <p>Thanks for using Thistle!</p> 228 + </div> 229 + </div> 230 + </body> 231 + </html> 232 + `.trim(); 233 + }
+160
src/lib/email-verification.test.ts
··· 1 + import { describe, test, expect, beforeEach, afterEach } from "bun:test"; 2 + import db from "../db/schema"; 3 + import { 4 + createUser, 5 + createEmailVerificationToken, 6 + verifyEmailToken, 7 + isEmailVerified, 8 + createPasswordResetToken, 9 + verifyPasswordResetToken, 10 + consumePasswordResetToken, 11 + } from "./auth"; 12 + 13 + describe("Email Verification", () => { 14 + let userId: number; 15 + const testEmail = `test-verify-${Date.now()}@example.com`; 16 + 17 + beforeEach(async () => { 18 + // Create test user 19 + const user = await createUser(testEmail, "a".repeat(64), "Test User"); 20 + userId = user.id; 21 + }); 22 + 23 + afterEach(() => { 24 + // Cleanup 25 + db.run("DELETE FROM users WHERE email = ?", [testEmail]); 26 + db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [ 27 + userId, 28 + ]); 29 + }); 30 + 31 + test("creates verification token", () => { 32 + const token = createEmailVerificationToken(userId); 33 + expect(token).toBeDefined(); 34 + expect(typeof token).toBe("string"); 35 + expect(token.length).toBeGreaterThan(0); 36 + }); 37 + 38 + test("verifies valid token", () => { 39 + const token = createEmailVerificationToken(userId); 40 + const result = verifyEmailToken(token); 41 + 42 + expect(result).not.toBeNull(); 43 + expect(result?.userId).toBe(userId); 44 + expect(result?.email).toBe(testEmail); 45 + expect(isEmailVerified(userId)).toBe(true); 46 + }); 47 + 48 + test("rejects invalid token", () => { 49 + const result = verifyEmailToken("invalid-token-12345"); 50 + expect(result).toBeNull(); 51 + expect(isEmailVerified(userId)).toBe(false); 52 + }); 53 + 54 + test("token is one-time use", () => { 55 + const token = createEmailVerificationToken(userId); 56 + 57 + // First use succeeds 58 + const firstResult = verifyEmailToken(token); 59 + expect(firstResult).not.toBeNull(); 60 + 61 + // Second use fails 62 + const secondResult = verifyEmailToken(token); 63 + expect(secondResult).toBeNull(); 64 + }); 65 + 66 + test("rejects expired token", () => { 67 + const token = createEmailVerificationToken(userId); 68 + 69 + // Manually expire the token 70 + db.run( 71 + "UPDATE email_verification_tokens SET expires_at = ? WHERE token = ?", 72 + [Math.floor(Date.now() / 1000) - 100, token], 73 + ); 74 + 75 + const result = verifyEmailToken(token); 76 + expect(result).toBeNull(); 77 + }); 78 + 79 + test("replaces existing token when creating new one", () => { 80 + const token1 = createEmailVerificationToken(userId); 81 + const token2 = createEmailVerificationToken(userId); 82 + 83 + // First token should be invalidated 84 + expect(verifyEmailToken(token1)).toBeNull(); 85 + 86 + // Second token should work 87 + expect(verifyEmailToken(token2)).not.toBeNull(); 88 + }); 89 + }); 90 + 91 + describe("Password Reset", () => { 92 + let userId: number; 93 + const testEmail = `test-reset-${Date.now()}@example.com`; 94 + 95 + beforeEach(async () => { 96 + const user = await createUser(testEmail, "a".repeat(64), "Test User"); 97 + userId = user.id; 98 + }); 99 + 100 + afterEach(() => { 101 + db.run("DELETE FROM users WHERE email = ?", [testEmail]); 102 + db.run("DELETE FROM password_reset_tokens WHERE user_id = ?", [userId]); 103 + }); 104 + 105 + test("creates reset token", () => { 106 + const token = createPasswordResetToken(userId); 107 + expect(token).toBeDefined(); 108 + expect(typeof token).toBe("string"); 109 + expect(token.length).toBeGreaterThan(0); 110 + }); 111 + 112 + test("verifies valid reset token", () => { 113 + const token = createPasswordResetToken(userId); 114 + const verifiedUserId = verifyPasswordResetToken(token); 115 + 116 + expect(verifiedUserId).toBe(userId); 117 + }); 118 + 119 + test("rejects invalid reset token", () => { 120 + const verifiedUserId = verifyPasswordResetToken("invalid-token-12345"); 121 + expect(verifiedUserId).toBeNull(); 122 + }); 123 + 124 + test("consumes reset token", () => { 125 + const token = createPasswordResetToken(userId); 126 + 127 + // Token works before consumption 128 + expect(verifyPasswordResetToken(token)).toBe(userId); 129 + 130 + // Consume token 131 + consumePasswordResetToken(token); 132 + 133 + // Token no longer works 134 + expect(verifyPasswordResetToken(token)).toBeNull(); 135 + }); 136 + 137 + test("rejects expired reset token", () => { 138 + const token = createPasswordResetToken(userId); 139 + 140 + // Manually expire the token 141 + db.run( 142 + "UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?", 143 + [Math.floor(Date.now() / 1000) - 100, token], 144 + ); 145 + 146 + const verifiedUserId = verifyPasswordResetToken(token); 147 + expect(verifiedUserId).toBeNull(); 148 + }); 149 + 150 + test("replaces existing reset token when creating new one", () => { 151 + const token1 = createPasswordResetToken(userId); 152 + const token2 = createPasswordResetToken(userId); 153 + 154 + // First token should be invalidated 155 + expect(verifyPasswordResetToken(token1)).toBeNull(); 156 + 157 + // Second token should work 158 + expect(verifyPasswordResetToken(token2)).toBe(userId); 159 + }); 160 + });
+100
src/lib/email.ts
··· 1 + /** 2 + * Email service using MailChannels API 3 + * Docs: https://api.mailchannels.net/tx/v1/documentation 4 + */ 5 + 6 + interface EmailAddress { 7 + email: string; 8 + name?: string; 9 + } 10 + 11 + interface EmailContent { 12 + type: "text/plain" | "text/html"; 13 + value: string; 14 + } 15 + 16 + interface SendEmailOptions { 17 + to: string | EmailAddress; 18 + subject: string; 19 + html?: string; 20 + text?: string; 21 + replyTo?: string; 22 + } 23 + 24 + /** 25 + * Send an email via MailChannels 26 + */ 27 + export async function sendEmail(options: SendEmailOptions): Promise<void> { 28 + const fromEmail = process.env.SMTP_FROM_EMAIL || "noreply@thistle.app"; 29 + const fromName = process.env.SMTP_FROM_NAME || "Thistle"; 30 + const dkimDomain = process.env.DKIM_DOMAIN || "thistle.app"; 31 + const dkimPrivateKey = process.env.DKIM_PRIVATE_KEY; 32 + const mailchannelsApiKey = process.env.MAILCHANNELS_API_KEY; 33 + 34 + if (!dkimPrivateKey) { 35 + throw new Error( 36 + "DKIM_PRIVATE_KEY environment variable is required for sending emails", 37 + ); 38 + } 39 + 40 + if (!mailchannelsApiKey) { 41 + throw new Error( 42 + "MAILCHANNELS_API_KEY environment variable is required for sending emails", 43 + ); 44 + } 45 + 46 + // Normalize recipient 47 + const recipient = 48 + typeof options.to === "string" ? { email: options.to } : options.to; 49 + 50 + // Build content array 51 + const content: EmailContent[] = []; 52 + if (options.text) { 53 + content.push({ type: "text/plain", value: options.text }); 54 + } 55 + if (options.html) { 56 + content.push({ type: "text/html", value: options.html }); 57 + } 58 + 59 + if (content.length === 0) { 60 + throw new Error("At least one of 'text' or 'html' must be provided"); 61 + } 62 + 63 + const payload = { 64 + personalizations: [ 65 + { 66 + to: [recipient], 67 + ...(options.replyTo && { 68 + reply_to: { email: options.replyTo }, 69 + }), 70 + dkim_domain: dkimDomain, 71 + dkim_selector: "mailchannels", 72 + dkim_private_key: dkimPrivateKey, 73 + }, 74 + ], 75 + from: { 76 + email: fromEmail, 77 + name: fromName, 78 + }, 79 + subject: options.subject, 80 + content, 81 + }; 82 + 83 + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { 84 + method: "POST", 85 + headers: { 86 + "content-type": "application/json", 87 + "X-Api-Key": mailchannelsApiKey, 88 + }, 89 + body: JSON.stringify(payload), 90 + }); 91 + 92 + if (!response.ok) { 93 + const errorText = await response.text(); 94 + throw new Error( 95 + `MailChannels API error (${response.status}): ${errorText}`, 96 + ); 97 + } 98 + 99 + console.log(`[Email] Sent "${options.subject}" to ${recipient.email}`); 100 + }
+18
src/lib/rate-limit.ts
··· 109 109 return null; // Allowed 110 110 } 111 111 112 + export function clearRateLimit(endpoint: string, email?: string, ipAddress?: string): void { 113 + // Clear account-based rate limits 114 + if (email) { 115 + db.run( 116 + "DELETE FROM rate_limit_attempts WHERE key = ?", 117 + [`${endpoint}:account:${email.toLowerCase()}`] 118 + ); 119 + } 120 + 121 + // Clear IP-based rate limits 122 + if (ipAddress) { 123 + db.run( 124 + "DELETE FROM rate_limit_attempts WHERE key = ?", 125 + [`${endpoint}:ip:${ipAddress}`] 126 + ); 127 + } 128 + } 129 + 112 130 export function cleanupOldAttempts(olderThanSeconds = 86400) { 113 131 // Clean up attempts older than specified time (default: 24 hours) 114 132 const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds;
+147
src/pages/reset-password.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 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>"> 10 + <link rel="stylesheet" href="../styles/main.css"> 11 + </head> 12 + 13 + <body> 14 + <auth-component></auth-component> 15 + 16 + <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> 69 + </main> 70 + 71 + <script type="module" src="../components/auth.ts"></script> 72 + <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 84 + const urlParams = new URLSearchParams(window.location.search); 85 + 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'; 91 + } 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 + </script> 145 + </body> 146 + 147 + </html>