🪻 distributed transcription service thistle.dunkirk.sh

chore: biome format

dunkirk.sh 0e3ccd2d c734cd5f

verified
+556 -304
+28 -28
package.json
··· 1 1 { 2 - "name": "thistle", 3 - "module": "src/index.ts", 4 - "type": "module", 5 - "private": true, 6 - "scripts": { 7 - "dev": "bun run src/index.ts --hot", 8 - "clean": "rm -rf transcripts uploads thistle.db", 9 - "test": "bun test", 10 - "test:integration": "bun test src/index.test.ts", 11 - "ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app" 12 - }, 13 - "devDependencies": { 14 - "@biomejs/biome": "^2.3.2", 15 - "@simplewebauthn/types": "^12.0.0", 16 - "@types/bun": "latest" 17 - }, 18 - "peerDependencies": { 19 - "typescript": "^5" 20 - }, 21 - "dependencies": { 22 - "@polar-sh/sdk": "^0.41.5", 23 - "@simplewebauthn/browser": "^13.2.2", 24 - "@simplewebauthn/server": "^13.2.2", 25 - "eventsource-client": "^1.2.0", 26 - "lit": "^3.3.1", 27 - "nanoid": "^5.1.6", 28 - "ua-parser-js": "^2.0.6" 29 - } 2 + "name": "thistle", 3 + "module": "src/index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun run src/index.ts --hot", 8 + "clean": "rm -rf transcripts uploads thistle.db", 9 + "test": "bun test", 10 + "test:integration": "bun test src/index.test.ts", 11 + "ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app" 12 + }, 13 + "devDependencies": { 14 + "@biomejs/biome": "^2.3.2", 15 + "@simplewebauthn/types": "^12.0.0", 16 + "@types/bun": "latest" 17 + }, 18 + "peerDependencies": { 19 + "typescript": "^5" 20 + }, 21 + "dependencies": { 22 + "@polar-sh/sdk": "^0.41.5", 23 + "@simplewebauthn/browser": "^13.2.2", 24 + "@simplewebauthn/server": "^13.2.2", 25 + "eventsource-client": "^1.2.0", 26 + "lit": "^3.3.1", 27 + "nanoid": "^5.1.6", 28 + "ua-parser-js": "^2.0.6" 29 + } 30 30 }
+19 -1
public/favicon/site.webmanifest
··· 1 - {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 1 + { 2 + "name": "", 3 + "short_name": "", 4 + "icons": [ 5 + { 6 + "src": "/android-chrome-192x192.png", 7 + "sizes": "192x192", 8 + "type": "image/png" 9 + }, 10 + { 11 + "src": "/android-chrome-512x512.png", 12 + "sizes": "512x512", 13 + "type": "image/png" 14 + } 15 + ], 16 + "theme_color": "#ffffff", 17 + "background_color": "#ffffff", 18 + "display": "standalone" 19 + }
+3 -1
scripts/clear-rate-limits.ts
··· 11 11 if (deletedCount === 0) { 12 12 console.log("ℹ️ No rate limit attempts to clear"); 13 13 } else { 14 - console.log(`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? '' : 's'}`); 14 + console.log( 15 + `✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? "" : "s"}`, 16 + ); 15 17 }
+1 -1
scripts/send-test-emails.ts
··· 5 5 6 6 import { sendEmail } from "../src/lib/email"; 7 7 import { 8 - verifyEmailTemplate, 9 8 passwordResetTemplate, 10 9 transcriptionCompleteTemplate, 10 + verifyEmailTemplate, 11 11 } from "../src/lib/email-templates"; 12 12 13 13 const targetEmail = process.argv[2];
+5 -5
src/components/admin-classes.ts
··· 467 467 468 468 override async connectedCallback() { 469 469 super.connectedCallback(); 470 - 470 + 471 471 // Check for subtab query parameter 472 472 const params = new URLSearchParams(window.location.search); 473 473 const subtab = params.get("subtab"); ··· 477 477 // Set default subtab in URL if on classes tab 478 478 this.setActiveTab(this.activeTab); 479 479 } 480 - 480 + 481 481 await this.loadData(); 482 482 } 483 483 ··· 526 526 private async handleToggleArchive(classId: string) { 527 527 try { 528 528 // Find the class to toggle its archived state 529 - const classToToggle = this.classes.find(c => c.id === classId); 529 + const classToToggle = this.classes.find((c) => c.id === classId); 530 530 if (!classToToggle) return; 531 531 532 532 const response = await fetch(`/api/classes/${classId}/archive`, { ··· 540 540 } 541 541 542 542 // Update local state instead of reloading 543 - this.classes = this.classes.map(c => 544 - c.id === classId ? { ...c, archived: !c.archived } : c 543 + this.classes = this.classes.map((c) => 544 + c.id === classId ? { ...c, archived: !c.archived } : c, 545 545 ); 546 546 } catch { 547 547 this.error = "Failed to update class. Please try again.";
+19 -10
src/components/admin-pending-recordings.ts
··· 246 246 this.isLoading = true; 247 247 this.error = null; 248 248 249 - try { 250 - // Get all classes with their transcriptions 251 - const response = await fetch("/api/classes"); 252 - if (!response.ok) { 253 - const data = await response.json(); 254 - throw new Error(data.error || "Failed to load classes"); 255 - } 249 + try { 250 + // Get all classes with their transcriptions 251 + const response = await fetch("/api/classes"); 252 + if (!response.ok) { 253 + const data = await response.json(); 254 + throw new Error(data.error || "Failed to load classes"); 255 + } 256 256 257 257 const data = await response.json(); 258 258 const classesGrouped = data.classes || {}; ··· 317 317 318 318 this.recordings = pendingRecordings; 319 319 } catch (err) { 320 - this.error = err instanceof Error ? err.message : "Failed to load pending recordings. Please try again."; 320 + this.error = 321 + err instanceof Error 322 + ? err.message 323 + : "Failed to load pending recordings. Please try again."; 321 324 } finally { 322 325 this.isLoading = false; 323 326 } ··· 338 341 // Reload recordings 339 342 await this.loadRecordings(); 340 343 } catch (err) { 341 - this.error = err instanceof Error ? err.message : "Failed to approve recording. Please try again."; 344 + this.error = 345 + err instanceof Error 346 + ? err.message 347 + : "Failed to approve recording. Please try again."; 342 348 } 343 349 } 344 350 ··· 365 371 // Reload recordings 366 372 await this.loadRecordings(); 367 373 } catch (err) { 368 - this.error = err instanceof Error ? err.message : "Failed to delete recording. Please try again."; 374 + this.error = 375 + err instanceof Error 376 + ? err.message 377 + : "Failed to delete recording. Please try again."; 369 378 } 370 379 } 371 380
+8 -2
src/components/admin-transcriptions.ts
··· 196 196 197 197 this.transcriptions = await response.json(); 198 198 } catch (err) { 199 - this.error = err instanceof Error ? err.message : "Failed to load transcriptions. Please try again."; 199 + this.error = 200 + err instanceof Error 201 + ? err.message 202 + : "Failed to load transcriptions. Please try again."; 200 203 } finally { 201 204 this.isLoading = false; 202 205 } ··· 228 231 await this.loadTranscriptions(); 229 232 this.dispatchEvent(new CustomEvent("transcription-deleted")); 230 233 } catch (err) { 231 - this.error = err instanceof Error ? err.message : "Failed to delete transcription. Please try again."; 234 + this.error = 235 + err instanceof Error 236 + ? err.message 237 + : "Failed to delete transcription. Please try again."; 232 238 } 233 239 } 234 240
+64 -30
src/components/admin-users.ts
··· 326 326 327 327 this.users = await response.json(); 328 328 } catch (err) { 329 - this.error = err instanceof Error ? err.message : "Failed to load users. Please try again."; 329 + this.error = 330 + err instanceof Error 331 + ? err.message 332 + : "Failed to load users. Please try again."; 330 333 } finally { 331 334 this.isLoading = false; 332 335 } ··· 389 392 await this.loadUsers(); 390 393 } 391 394 } catch (err) { 392 - this.error = err instanceof Error ? err.message : "Failed to update user role"; 395 + this.error = 396 + err instanceof Error ? err.message : "Failed to update user role"; 393 397 select.value = oldRole; 394 398 } 395 399 } ··· 460 464 } 461 465 462 466 // Remove user from local array instead of reloading 463 - this.users = this.users.filter(u => u.id !== userId); 467 + this.users = this.users.filter((u) => u.id !== userId); 464 468 this.dispatchEvent(new CustomEvent("user-deleted")); 465 469 } catch (err) { 466 - this.error = err instanceof Error ? err.message : "Failed to delete user. Please try again."; 470 + this.error = 471 + err instanceof Error 472 + ? err.message 473 + : "Failed to delete user. Please try again."; 467 474 } 468 475 } 469 476 470 - private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) { 477 + private handleRevokeClick( 478 + userId: number, 479 + email: string, 480 + subscriptionId: string, 481 + event: Event, 482 + ) { 471 483 event.stopPropagation(); 472 484 473 485 // If this is a different item or timeout expired, reset ··· 510 522 this.deleteState = null; 511 523 }, 1000); 512 524 513 - this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout }; 525 + this.deleteState = { 526 + id: userId, 527 + type: "revoke", 528 + clicks: newClicks, 529 + timeout, 530 + }; 514 531 } 515 532 516 - private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) { 533 + private async performRevokeSubscription( 534 + userId: number, 535 + _email: string, 536 + subscriptionId: string, 537 + ) { 517 538 this.revokingSubscriptions.add(userId); 518 539 this.requestUpdate(); 519 540 this.error = null; ··· 532 553 533 554 await this.loadUsers(); 534 555 } catch (err) { 535 - this.error = err instanceof Error ? err.message : "Failed to revoke subscription"; 556 + this.error = 557 + err instanceof Error ? err.message : "Failed to revoke subscription"; 536 558 this.revokingSubscriptions.delete(userId); 537 559 } 538 560 } ··· 591 613 if (userId === 0) { 592 614 return; 593 615 } 594 - 616 + 595 617 // Don't open modal if clicking on delete button, revoke button, sync button, or role select 596 618 if ( 597 619 (event.target as HTMLElement).closest(".delete-btn") || ··· 616 638 617 639 private get filteredUsers() { 618 640 const query = this.searchQuery.toLowerCase(); 619 - 641 + 620 642 // Filter users based on search query 621 643 let filtered = this.users.filter( 622 644 (u) => 623 645 u.email.toLowerCase().includes(query) || 624 646 u.name?.toLowerCase().includes(query), 625 647 ); 626 - 648 + 627 649 // Hide ghost user unless specifically searched for 628 - if (!query.includes("deleted") && !query.includes("ghost") && !query.includes("system")) { 629 - filtered = filtered.filter(u => u.id !== 0); 650 + if ( 651 + !query.includes("deleted") && 652 + !query.includes("ghost") && 653 + !query.includes("system") 654 + ) { 655 + filtered = filtered.filter((u) => u.id !== 0); 630 656 } 631 - 657 + 632 658 return filtered; 633 659 } 634 660 ··· 666 692 <div class="users-grid"> 667 693 ${filtered.map( 668 694 (u) => html` 669 - <div class="user-card ${u.id === 0 ? 'system' : ''}" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 695 + <div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 670 696 <div class="card-header"> 671 697 <div class="user-info"> 672 698 <img ··· 679 705 <div class="user-email">${u.email}</div> 680 706 </div> 681 707 </div> 682 - ${u.id === 0 683 - ? html`<span class="system-badge">System</span>` 684 - : u.role === "admin" 685 - ? html`<span class="admin-badge">Admin</span>` 686 - : "" 687 - } 708 + ${ 709 + u.id === 0 710 + ? html`<span class="system-badge">System</span>` 711 + : u.role === "admin" 712 + ? html`<span class="admin-badge">Admin</span>` 713 + : "" 714 + } 688 715 </div> 689 716 690 717 <div class="meta-row"> ··· 695 722 <div class="meta-item"> 696 723 <div class="meta-label">Subscription</div> 697 724 <div class="meta-value"> 698 - ${u.subscription_status 699 - ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>` 700 - : html`<span class="subscription-badge none">None</span>` 701 - } 725 + ${ 726 + u.subscription_status 727 + ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>` 728 + : html`<span class="subscription-badge none">None</span>` 729 + } 702 730 </div> 703 731 </div> 704 732 <div class="meta-item"> ··· 716 744 </div> 717 745 718 746 <div class="actions"> 719 - ${u.id === 0 720 - ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>` 721 - : html` 747 + ${ 748 + u.id === 0 749 + ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>` 750 + : html` 722 751 <select 723 752 class="role-select" 724 753 .value=${u.role} ··· 740 769 ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)} 741 770 @click=${(e: Event) => { 742 771 if (u.subscription_id) { 743 - this.handleRevokeClick(u.id, u.email, u.subscription_id, e); 772 + this.handleRevokeClick( 773 + u.id, 774 + u.email, 775 + u.subscription_id, 776 + e, 777 + ); 744 778 } 745 779 }} 746 780 > ··· 750 784 ${this.getDeleteButtonText(u.id, "user")} 751 785 </button> 752 786 ` 753 - } 787 + } 754 788 </div> 755 789 </div> 756 790 `,
+10 -10
src/components/auth.ts
··· 440 440 } 441 441 442 442 const data = await response.json(); 443 - 443 + 444 444 if (data.email_verification_required) { 445 445 this.needsEmailVerification = true; 446 446 this.password = ""; ··· 478 478 } 479 479 480 480 const data = await response.json(); 481 - 481 + 482 482 if (data.email_verification_required) { 483 483 this.needsEmailVerification = true; 484 484 this.password = ""; ··· 608 608 private startResendTimer(sentAtTimestamp: number) { 609 609 // Use provided timestamp 610 610 this.codeSentAt = sentAtTimestamp; 611 - 611 + 612 612 // Clear existing interval if any 613 613 if (this.resendInterval !== null) { 614 614 clearInterval(this.resendInterval); 615 615 } 616 - 616 + 617 617 // Update timer based on elapsed time 618 618 const updateTimer = () => { 619 619 if (this.codeSentAt === null) return; 620 - 620 + 621 621 const now = Math.floor(Date.now() / 1000); 622 622 const elapsed = now - this.codeSentAt; 623 - const remaining = Math.max(0, (5 * 60) - elapsed); 623 + const remaining = Math.max(0, 5 * 60 - elapsed); 624 624 this.resendCodeTimer = remaining; 625 - 625 + 626 626 if (remaining <= 0) { 627 627 if (this.resendInterval !== null) { 628 628 clearInterval(this.resendInterval); ··· 630 630 } 631 631 } 632 632 }; 633 - 633 + 634 634 // Update immediately 635 635 updateTimer(); 636 - 636 + 637 637 // Then update every second 638 638 this.resendInterval = window.setInterval(updateTimer, 1000); 639 639 } ··· 671 671 private formatTimer(seconds: number): string { 672 672 const mins = Math.floor(seconds / 60); 673 673 const secs = seconds % 60; 674 - return `${mins}:${secs.toString().padStart(2, '0')}`; 674 + return `${mins}:${secs.toString().padStart(2, "0")}`; 675 675 } 676 676 677 677 override disconnectedCallback() {
+11 -7
src/components/class-view.ts
··· 538 538 : "" 539 539 } 540 540 541 - ${!canAccessTranscriptions ? html` 541 + ${ 542 + !canAccessTranscriptions 543 + ? html` 542 544 <div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin: 2rem 0; text-align: center;"> 543 545 <h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Access Recordings</h3> 544 546 <p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p> 545 547 <a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a> 546 548 </div> 547 - ` : html` 549 + ` 550 + : html` 548 551 <div class="search-upload"> 549 552 <input 550 553 type="text" ··· 572 575 <p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p> 573 576 </div> 574 577 ` 575 - : html` 578 + : html` 576 579 ${this.filteredTranscriptions.map( 577 - (t) => html` 580 + (t) => html` 578 581 <div class="transcription-card"> 579 582 <div class="transcription-header"> 580 583 <div> ··· 617 620 ${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""} 618 621 </div> 619 622 `, 620 - )} 623 + )} 621 624 ` 622 - } 623 - `} 625 + } 626 + ` 627 + } 624 628 </div> 625 629 626 630 <upload-recording-modal
+18 -7
src/components/reset-password-form.ts
··· 155 155 156 156 override async updated(changedProperties: Map<string, unknown>) { 157 157 super.updated(changedProperties); 158 - 158 + 159 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) { 160 + if ( 161 + changedProperties.has("token") && 162 + this.token && 163 + !this.email && 164 + !this.isLoadingEmail 165 + ) { 161 166 await this.loadEmail(); 162 167 } 163 168 } ··· 177 182 178 183 this.email = data.email; 179 184 } catch (err) { 180 - this.error = err instanceof Error ? err.message : "Failed to verify reset token"; 185 + this.error = 186 + err instanceof Error ? err.message : "Failed to verify reset token"; 181 187 } finally { 182 188 this.isLoadingEmail = false; 183 189 } ··· 230 236 <h1 class="reset-title">Reset Password</h1> 231 237 232 238 <form @submit=${this.handleSubmit}> 233 - ${this.error 234 - ? html`<div class="error-banner">${this.error}</div>` 235 - : ""} 239 + ${ 240 + this.error 241 + ? html`<div class="error-banner">${this.error}</div>` 242 + : "" 243 + } 236 244 237 245 <div class="form-group"> 238 246 <label for="password">New Password</label> ··· 298 306 } 299 307 300 308 // Hash password client-side with user's email 301 - const hashedPassword = await hashPasswordClient(this.password, this.email); 309 + const hashedPassword = await hashPasswordClient( 310 + this.password, 311 + this.email, 312 + ); 302 313 303 314 const response = await fetch("/api/auth/reset-password", { 304 315 method: "POST",
+8 -3
src/components/transcription.ts
··· 792 792 } 793 793 794 794 override render() { 795 - const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin); 795 + const canUpload = 796 + this.serviceAvailable && (this.hasSubscription || this.isAdmin); 796 797 797 798 return html` 798 - ${!this.hasSubscription && !this.isAdmin ? html` 799 + ${ 800 + !this.hasSubscription && !this.isAdmin 801 + ? html` 799 802 <div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;"> 800 803 <h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3> 801 804 <p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p> 802 805 <a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a> 803 806 </div> 804 - ` : ''} 807 + ` 808 + : "" 809 + } 805 810 806 811 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}" 807 812 @dragover=${canUpload ? this.handleDragOver : null}
+14 -6
src/components/user-modal.ts
··· 410 410 e.preventDefault(); 411 411 const form = e.target as HTMLFormElement; 412 412 const input = form.querySelector('input[type="email"]') as HTMLInputElement; 413 - const checkbox = form.querySelector('input[type="checkbox"]') as HTMLInputElement; 413 + const checkbox = form.querySelector( 414 + 'input[type="checkbox"]', 415 + ) as HTMLInputElement; 414 416 const email = input.value.trim(); 415 417 const skipVerification = checkbox?.checked || false; 416 418 ··· 470 472 submitBtn.textContent = "Sending..."; 471 473 472 474 try { 473 - const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, { 474 - method: "POST", 475 - headers: { "Content-Type": "application/json" }, 476 - }); 475 + const res = await fetch( 476 + `/api/admin/users/${this.userId}/password-reset`, 477 + { 478 + method: "POST", 479 + headers: { "Content-Type": "application/json" }, 480 + }, 481 + ); 477 482 478 483 if (!res.ok) { 479 484 const data = await res.json(); ··· 484 489 "Password reset email sent successfully. The user will receive a link to set a new password.", 485 490 ); 486 491 } catch (err) { 487 - this.error = err instanceof Error ? err.message : "Failed to send password reset email"; 492 + this.error = 493 + err instanceof Error 494 + ? err.message 495 + : "Failed to send password reset email"; 488 496 } finally { 489 497 submitBtn.disabled = false; 490 498 submitBtn.textContent = "Send Reset Email";
+106 -44
src/components/user-settings.ts
··· 36 36 canceled_at: number | null; 37 37 } 38 38 39 - type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger"; 39 + type SettingsPage = 40 + | "account" 41 + | "sessions" 42 + | "passkeys" 43 + | "billing" 44 + | "notifications" 45 + | "danger"; 40 46 41 47 @customElement("user-settings") 42 48 export class UserSettings extends LitElement { ··· 544 550 override async connectedCallback() { 545 551 super.connectedCallback(); 546 552 this.passkeySupported = isPasskeySupported(); 547 - 553 + 548 554 // Check for tab query parameter 549 555 const params = new URLSearchParams(window.location.search); 550 556 const tab = params.get("tab"); 551 557 if (tab && this.isValidTab(tab)) { 552 558 this.currentPage = tab as SettingsPage; 553 559 } 554 - 560 + 555 561 await this.loadUser(); 556 562 await this.loadSessions(); 557 563 await this.loadSubscription(); ··· 561 567 } 562 568 563 569 private isValidTab(tab: string): boolean { 564 - return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab); 570 + return [ 571 + "account", 572 + "sessions", 573 + "passkeys", 574 + "billing", 575 + "notifications", 576 + "danger", 577 + ].includes(tab); 565 578 } 566 579 567 580 private setTab(tab: SettingsPage) { ··· 674 687 // Reload passkeys 675 688 await this.loadPasskeys(); 676 689 } catch (err) { 677 - this.error = err instanceof Error ? err.message : "Failed to delete passkey"; 690 + this.error = 691 + err instanceof Error ? err.message : "Failed to delete passkey"; 678 692 } 679 693 } 680 694 ··· 682 696 this.error = ""; 683 697 try { 684 698 const response = await fetch("/api/auth/logout", { method: "POST" }); 685 - 699 + 686 700 if (!response.ok) { 687 701 const data = await response.json(); 688 702 this.error = data.error || "Failed to logout"; 689 703 return; 690 704 } 691 - 705 + 692 706 window.location.href = "/"; 693 707 } catch (err) { 694 708 this.error = err instanceof Error ? err.message : "Failed to logout"; ··· 699 713 this.deletingAccount = true; 700 714 this.error = ""; 701 715 document.body.style.cursor = "wait"; 702 - 716 + 703 717 try { 704 718 const response = await fetch("/api/user", { 705 719 method: "DELETE", ··· 1023 1037 1024 1038 return html` 1025 1039 <div class="content-inner"> 1026 - ${this.error ? html` 1040 + ${ 1041 + this.error 1042 + ? html` 1027 1043 <div class="error-banner"> 1028 1044 ${this.error} 1029 1045 </div> 1030 - ` : ""} 1046 + ` 1047 + : "" 1048 + } 1031 1049 <div class="section"> 1032 1050 <h2 class="section-title">Profile Information</h2> 1033 1051 ··· 1069 1087 ? html` 1070 1088 <div class="success-message" style="margin-bottom: 1rem;"> 1071 1089 ${this.emailChangeMessage} 1072 - ${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ''} 1090 + ${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ""} 1073 1091 </div> 1074 1092 <div class="field-row"> 1075 1093 <div class="field-value">${this.user.email}</div> 1076 1094 </div> 1077 1095 ` 1078 1096 : this.editingEmail 1079 - ? html` 1097 + ? html` 1080 1098 <div style="display: flex; gap: 0.5rem; align-items: center;"> 1081 1099 <input 1082 1100 type="email" ··· 1092 1110 @click=${this.handleUpdateEmail} 1093 1111 ?disabled=${this.updatingEmail} 1094 1112 > 1095 - ${this.updatingEmail ? html`<span class="spinner"></span>` : 'Save'} 1113 + ${this.updatingEmail ? html`<span class="spinner"></span>` : "Save"} 1096 1114 </button> 1097 1115 <button 1098 1116 class="btn btn-neutral btn-small" ··· 1105 1123 </button> 1106 1124 </div> 1107 1125 ` 1108 - : html` 1126 + : html` 1109 1127 <div class="field-row"> 1110 1128 <div class="field-value">${this.user.email}</div> 1111 1129 <button ··· 1248 1266 renderSessionsPage() { 1249 1267 return html` 1250 1268 <div class="content-inner"> 1251 - ${this.error ? html` 1269 + ${ 1270 + this.error 1271 + ? html` 1252 1272 <div class="error-banner"> 1253 1273 ${this.error} 1254 1274 </div> 1255 - ` : ""} 1275 + ` 1276 + : "" 1277 + } 1256 1278 <div class="section"> 1257 1279 <h2 class="section-title">Active Sessions</h2> 1258 1280 ${ ··· 1330 1352 `; 1331 1353 } 1332 1354 1333 - const hasActiveSubscription = this.subscription && ( 1334 - this.subscription.status === "active" || 1335 - this.subscription.status === "trialing" 1336 - ); 1355 + const hasActiveSubscription = 1356 + this.subscription && 1357 + (this.subscription.status === "active" || 1358 + this.subscription.status === "trialing"); 1337 1359 1338 1360 if (this.subscription && !hasActiveSubscription) { 1339 1361 // Has a subscription but it's not active (canceled, expired, etc.) 1340 - const statusColor = 1341 - this.subscription.status === "canceled" ? "var(--accent)" : 1342 - "var(--secondary)"; 1362 + const statusColor = 1363 + this.subscription.status === "canceled" 1364 + ? "var(--accent)" 1365 + : "var(--secondary)"; 1343 1366 1344 1367 return html` 1345 1368 <div class="content-inner"> 1346 - ${this.error ? html` 1369 + ${ 1370 + this.error 1371 + ? html` 1347 1372 <div class="error-banner"> 1348 1373 ${this.error} 1349 1374 </div> 1350 - ` : ""} 1375 + ` 1376 + : "" 1377 + } 1351 1378 <div class="section"> 1352 1379 <h2 class="section-title">Subscription</h2> 1353 1380 ··· 1369 1396 </div> 1370 1397 </div> 1371 1398 1372 - ${this.subscription.canceled_at ? html` 1399 + ${ 1400 + this.subscription.canceled_at 1401 + ? html` 1373 1402 <div class="field-group"> 1374 1403 <label class="field-label">Canceled At</label> 1375 1404 <div class="field-value" style="color: var(--accent);"> 1376 1405 ${this.formatDate(this.subscription.canceled_at)} 1377 1406 </div> 1378 1407 </div> 1379 - ` : ""} 1408 + ` 1409 + : "" 1410 + } 1380 1411 1381 1412 <div class="field-group" style="margin-top: 2rem;"> 1382 1413 <button ··· 1398 1429 if (hasActiveSubscription) { 1399 1430 return html` 1400 1431 <div class="content-inner"> 1401 - ${this.error ? html` 1432 + ${ 1433 + this.error 1434 + ? html` 1402 1435 <div class="error-banner"> 1403 1436 ${this.error} 1404 1437 </div> 1405 - ` : ""} 1438 + ` 1439 + : "" 1440 + } 1406 1441 <div class="section"> 1407 1442 <h2 class="section-title">Subscription</h2> 1408 1443 ··· 1421 1456 "> 1422 1457 ${this.subscription.status} 1423 1458 </span> 1424 - ${this.subscription.cancel_at_period_end ? html` 1459 + ${ 1460 + this.subscription.cancel_at_period_end 1461 + ? html` 1425 1462 <span style="color: var(--accent); font-size: 0.875rem;"> 1426 1463 (Cancels at end of period) 1427 1464 </span> 1428 - ` : ""} 1465 + ` 1466 + : "" 1467 + } 1429 1468 </div> 1430 1469 </div> 1431 1470 1432 - ${this.subscription.current_period_start && this.subscription.current_period_end ? html` 1471 + ${ 1472 + this.subscription.current_period_start && 1473 + this.subscription.current_period_end 1474 + ? html` 1433 1475 <div class="field-group"> 1434 1476 <label class="field-label">Current Period</label> 1435 1477 <div class="field-value"> ··· 1437 1479 ${this.formatDate(this.subscription.current_period_end)} 1438 1480 </div> 1439 1481 </div> 1440 - ` : ""} 1482 + ` 1483 + : "" 1484 + } 1441 1485 1442 1486 <div class="field-group" style="margin-top: 2rem;"> 1443 1487 <button ··· 1458 1502 1459 1503 return html` 1460 1504 <div class="content-inner"> 1461 - ${this.error ? html` 1505 + ${ 1506 + this.error 1507 + ? html` 1462 1508 <div class="error-banner"> 1463 1509 ${this.error} 1464 1510 </div> 1465 - ` : ""} 1511 + ` 1512 + : "" 1513 + } 1466 1514 <div class="section"> 1467 1515 <h2 class="section-title">Billing & Subscription</h2> 1468 1516 <p class="field-description" style="margin-bottom: 1.5rem;"> ··· 1483 1531 renderDangerPage() { 1484 1532 return html` 1485 1533 <div class="content-inner"> 1486 - ${this.error ? html` 1534 + ${ 1535 + this.error 1536 + ? html` 1487 1537 <div class="error-banner"> 1488 1538 ${this.error} 1489 1539 </div> 1490 - ` : ""} 1540 + ` 1541 + : "" 1542 + } 1491 1543 <div class="section danger-section"> 1492 1544 <h2 class="section-title">Delete Account</h2> 1493 1545 <p class="danger-text"> ··· 1510 1562 renderNotificationsPage() { 1511 1563 return html` 1512 1564 <div class="content-inner"> 1513 - ${this.error ? html` 1565 + ${ 1566 + this.error 1567 + ? html` 1514 1568 <div class="error-banner"> 1515 1569 ${this.error} 1516 1570 </div> 1517 - ` : ""} 1571 + ` 1572 + : "" 1573 + } 1518 1574 <div class="section"> 1519 1575 <h2 class="section-title">Email Notifications</h2> 1520 1576 <p style="color: var(--text); margin-bottom: 1rem;"> ··· 1536 1592 const target = e.target as HTMLInputElement; 1537 1593 this.emailNotificationsEnabled = target.checked; 1538 1594 this.error = ""; 1539 - 1595 + 1540 1596 try { 1541 1597 const response = await fetch("/api/user/notifications", { 1542 1598 method: "PUT", 1543 1599 headers: { "Content-Type": "application/json" }, 1544 1600 body: JSON.stringify({ 1545 - email_notifications_enabled: this.emailNotificationsEnabled, 1601 + email_notifications_enabled: 1602 + this.emailNotificationsEnabled, 1546 1603 }), 1547 1604 }); 1548 - 1605 + 1549 1606 if (!response.ok) { 1550 1607 const data = await response.json(); 1551 - throw new Error(data.error || "Failed to update notification settings"); 1608 + throw new Error( 1609 + data.error || "Failed to update notification settings", 1610 + ); 1552 1611 } 1553 1612 } catch (err) { 1554 1613 // Revert on error 1555 1614 this.emailNotificationsEnabled = !target.checked; 1556 1615 target.checked = !target.checked; 1557 - this.error = err instanceof Error ? err.message : "Failed to update notification settings"; 1616 + this.error = 1617 + err instanceof Error 1618 + ? err.message 1619 + : "Failed to update notification settings"; 1558 1620 } 1559 1621 }} 1560 1622 />
+153 -80
src/index.ts
··· 2 2 import { 3 3 authenticateUser, 4 4 cleanupExpiredSessions, 5 + consumeEmailChangeToken, 6 + consumePasswordResetToken, 7 + createEmailChangeToken, 8 + createEmailVerificationToken, 9 + createPasswordResetToken, 5 10 createSession, 6 11 createUser, 7 12 deleteAllUserSessions, ··· 17 22 getUserByEmail, 18 23 getUserBySession, 19 24 getUserSessionsForUser, 25 + getVerificationCodeSentAt, 26 + isEmailVerified, 20 27 type UserRole, 21 28 updateUserAvatar, 22 29 updateUserEmail, ··· 24 31 updateUserName, 25 32 updateUserPassword, 26 33 updateUserRole, 27 - createEmailVerificationToken, 28 - verifyEmailToken, 34 + verifyEmailChangeToken, 29 35 verifyEmailCode, 30 - isEmailVerified, 31 - getVerificationCodeSentAt, 32 - createPasswordResetToken, 36 + verifyEmailToken, 33 37 verifyPasswordResetToken, 34 - consumePasswordResetToken, 35 - createEmailChangeToken, 36 - verifyEmailChangeToken, 37 - consumeEmailChangeToken, 38 38 } from "./lib/auth"; 39 39 import { 40 40 addToWaitlist, ··· 57 57 toggleClassArchive, 58 58 updateMeetingTime, 59 59 } from "./lib/classes"; 60 + import { sendEmail } from "./lib/email"; 61 + import { 62 + emailChangeTemplate, 63 + passwordResetTemplate, 64 + verifyEmailTemplate, 65 + } from "./lib/email-templates"; 60 66 import { AuthErrors, handleError, ValidationErrors } from "./lib/errors"; 61 67 import { 62 68 hasActiveSubscription, ··· 73 79 verifyAndAuthenticatePasskey, 74 80 verifyAndCreatePasskey, 75 81 } from "./lib/passkey"; 76 - import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit"; 82 + import { clearRateLimit, enforceRateLimit } from "./lib/rate-limit"; 77 83 import { getTranscriptVTT } from "./lib/transcript-storage"; 78 84 import { 79 85 MAX_FILE_SIZE, ··· 81 87 type TranscriptionUpdate, 82 88 WhisperServiceManager, 83 89 } from "./lib/transcription"; 84 - import { sendEmail } from "./lib/email"; 85 - import { 86 - verifyEmailTemplate, 87 - passwordResetTemplate, 88 - emailChangeTemplate, 89 - } from "./lib/email-templates"; 90 90 import adminHTML from "./pages/admin.html"; 91 91 import checkoutHTML from "./pages/checkout.html"; 92 92 import classHTML from "./pages/class.html"; ··· 144 144 customerId: customer.id, 145 145 }); 146 146 147 - if (!subscriptions.result.items || subscriptions.result.items.length === 0) { 147 + if ( 148 + !subscriptions.result.items || 149 + subscriptions.result.items.length === 0 150 + ) { 148 151 console.log(`[Sync] No subscriptions found for customer ${customer.id}`); 149 152 return; 150 153 } 151 154 152 155 // Filter to only active/trialing/past_due subscriptions (not canceled/expired) 153 156 const currentSubscriptions = subscriptions.result.items.filter( 154 - (sub) => sub.status === 'active' || sub.status === 'trialing' || sub.status === 'past_due' 157 + (sub) => 158 + sub.status === "active" || 159 + sub.status === "trialing" || 160 + sub.status === "past_due", 155 161 ); 156 162 157 163 if (currentSubscriptions.length === 0) { 158 - console.log(`[Sync] No current subscriptions found for customer ${customer.id}`); 164 + console.log( 165 + `[Sync] No current subscriptions found for customer ${customer.id}`, 166 + ); 159 167 return; 160 168 } 161 169 ··· 207 215 // Don't throw - registration should succeed even if sync fails 208 216 } 209 217 } 210 - 211 218 212 219 // Sync with Whisper DB on startup 213 220 try { ··· 280 287 ); 281 288 } 282 289 const user = await createUser(email, password, name); 283 - 290 + 284 291 // Send verification email - MUST succeed for registration to complete 285 292 const { code, token, sentAt } = createEmailVerificationToken(user.id); 286 - 293 + 287 294 try { 288 295 await sendEmail({ 289 296 to: user.email, ··· 297 304 } catch (err) { 298 305 console.error("[Email] Failed to send verification email:", err); 299 306 // Rollback user creation - direct DB delete since user was just created 300 - db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]); 307 + db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [ 308 + user.id, 309 + ]); 301 310 db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]); 302 311 db.run("DELETE FROM users WHERE id = ?", [user.id]); 303 312 return Response.json( 304 - { error: "Failed to send verification email. Please try again later." }, 313 + { 314 + error: 315 + "Failed to send verification email. Please try again later.", 316 + }, 305 317 { status: 500 }, 306 318 ); 307 319 } 308 - 320 + 309 321 // Attempt to sync existing Polar subscriptions (after email succeeds) 310 322 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => { 311 323 // Silent fail - don't block registration 312 324 }); 313 - 325 + 314 326 // Clear rate limits on successful registration 315 327 const ipAddress = 316 328 req.headers.get("x-forwarded-for") ?? 317 329 req.headers.get("x-real-ip") ?? 318 330 "unknown"; 319 331 clearRateLimit("register", email, ipAddress); 320 - 332 + 321 333 // Return success but indicate email verification is needed 322 334 // Don't create session yet - they need to verify first 323 335 return Response.json( 324 - { 336 + { 325 337 user: { id: user.id, email: user.email }, 326 338 email_verification_required: true, 327 339 verification_code_sent_at: sentAt, ··· 377 389 { status: 401 }, 378 390 ); 379 391 } 380 - 392 + 381 393 // Clear rate limits on successful authentication 382 394 const ipAddress = 383 395 req.headers.get("x-forwarded-for") ?? 384 396 req.headers.get("x-real-ip") ?? 385 397 "unknown"; 386 398 clearRateLimit("login", email, ipAddress); 387 - 399 + 388 400 // Check if email is verified 389 401 if (!isEmailVerified(user.id)) { 390 402 let codeSentAt = getVerificationCodeSentAt(user.id); 391 - 403 + 392 404 // If no verification code exists, auto-send one 393 405 if (!codeSentAt) { 394 - const { code, token, sentAt } = createEmailVerificationToken(user.id); 406 + const { code, token, sentAt } = createEmailVerificationToken( 407 + user.id, 408 + ); 395 409 codeSentAt = sentAt; 396 - 410 + 397 411 try { 398 412 await sendEmail({ 399 413 to: user.email, ··· 405 419 }), 406 420 }); 407 421 } catch (err) { 408 - console.error("[Email] Failed to send verification email on login:", err); 422 + console.error( 423 + "[Email] Failed to send verification email on login:", 424 + err, 425 + ); 409 426 // Don't fail login - just return null timestamp so client can try resend 410 427 codeSentAt = null; 411 428 } 412 429 } 413 - 430 + 414 431 return Response.json( 415 - { 432 + { 416 433 user: { id: user.id, email: user.email }, 417 434 email_verification_required: true, 418 435 verification_code_sent_at: codeSentAt, ··· 420 437 { status: 200 }, 421 438 ); 422 439 } 423 - 440 + 424 441 const userAgent = req.headers.get("user-agent") ?? "unknown"; 425 442 const sessionId = createSession(user.id, ipAddress, userAgent); 426 443 return Response.json( ··· 465 482 return new Response(null, { 466 483 status: 302, 467 484 headers: { 468 - "Location": "/classes", 485 + Location: "/classes", 469 486 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 470 487 }, 471 488 }); ··· 489 506 // Get user by email 490 507 const user = getUserByEmail(email); 491 508 if (!user) { 492 - return Response.json( 493 - { error: "User not found" }, 494 - { status: 404 }, 495 - ); 509 + return Response.json({ error: "User not found" }, { status: 404 }); 496 510 } 497 511 498 512 // Check if already verified ··· 521 535 const sessionId = createSession(user.id, ipAddress, userAgent); 522 536 523 537 return Response.json( 524 - { 538 + { 525 539 message: "Email verified successfully", 526 540 email_verified: true, 527 541 user: { id: user.id, email: user.email }, ··· 541 555 POST: async (req) => { 542 556 try { 543 557 const user = requireAuth(req); 544 - 558 + 545 559 // Rate limiting 546 560 const rateLimitError = enforceRateLimit(req, "resend-verification", { 547 561 account: { max: 3, windowSeconds: 60 * 60, email: user.email }, ··· 586 600 } 587 601 588 602 // Rate limiting by email 589 - const rateLimitError = enforceRateLimit(req, "resend-verification-code", { 590 - account: { max: 3, windowSeconds: 5 * 60, email }, 591 - }); 603 + const rateLimitError = enforceRateLimit( 604 + req, 605 + "resend-verification-code", 606 + { 607 + account: { max: 3, windowSeconds: 5 * 60, email }, 608 + }, 609 + ); 592 610 if (rateLimitError) return rateLimitError; 593 611 594 612 // Get user by email 595 613 const user = getUserByEmail(email); 596 614 if (!user) { 597 615 // Don't reveal if user exists 598 - return Response.json({ message: "If an account exists with that email, a verification code has been sent" }); 616 + return Response.json({ 617 + message: 618 + "If an account exists with that email, a verification code has been sent", 619 + }); 599 620 } 600 621 601 622 // Check if already verified ··· 619 640 }), 620 641 }); 621 642 622 - return Response.json({ 643 + return Response.json({ 623 644 message: "Verification code sent", 624 645 verification_code_sent_at: sentAt, 625 646 }); ··· 683 704 const token = url.searchParams.get("token"); 684 705 685 706 if (!token) { 686 - return Response.json( 687 - { error: "Token required" }, 688 - { status: 400 }, 689 - ); 707 + return Response.json({ error: "Token required" }, { status: 400 }); 690 708 } 691 709 692 710 const userId = verifyPasswordResetToken(token); ··· 699 717 700 718 // Get user's email for client-side password hashing 701 719 const user = db 702 - .query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?") 720 + .query<{ email: string }, [number]>( 721 + "SELECT email FROM users WHERE id = ?", 722 + ) 703 723 .get(userId); 704 724 705 725 if (!user) { ··· 1089 1109 }), 1090 1110 }); 1091 1111 1092 - return Response.json({ 1112 + return Response.json({ 1093 1113 success: true, 1094 1114 message: `Verification email sent to ${user.email}`, 1095 - pendingEmail: email 1115 + pendingEmail: email, 1096 1116 }); 1097 1117 } catch (error) { 1098 - console.error("[Email] Failed to send email change verification:", error); 1118 + console.error( 1119 + "[Email] Failed to send email change verification:", 1120 + error, 1121 + ); 1099 1122 return Response.json( 1100 1123 { error: "Failed to send verification email" }, 1101 1124 { status: 500 }, ··· 1110 1133 const token = url.searchParams.get("token"); 1111 1134 1112 1135 if (!token) { 1113 - return Response.redirect("/settings?tab=account&error=invalid-token", 302); 1136 + return Response.redirect( 1137 + "/settings?tab=account&error=invalid-token", 1138 + 302, 1139 + ); 1114 1140 } 1115 1141 1116 1142 const result = verifyEmailChangeToken(token); 1117 1143 1118 1144 if (!result) { 1119 - return Response.redirect("/settings?tab=account&error=expired-token", 302); 1145 + return Response.redirect( 1146 + "/settings?tab=account&error=expired-token", 1147 + 302, 1148 + ); 1120 1149 } 1121 1150 1122 1151 // Update the user's email ··· 1126 1155 consumeEmailChangeToken(token); 1127 1156 1128 1157 // Redirect to settings with success message 1129 - return Response.redirect("/settings?tab=account&success=email-changed", 302); 1158 + return Response.redirect( 1159 + "/settings?tab=account&success=email-changed", 1160 + 302, 1161 + ); 1130 1162 } catch (error) { 1131 1163 console.error("[Email] Email change verification error:", error); 1132 - return Response.redirect("/settings?tab=account&error=verification-failed", 302); 1164 + return Response.redirect( 1165 + "/settings?tab=account&error=verification-failed", 1166 + 302, 1167 + ); 1133 1168 } 1134 1169 }, 1135 1170 }, ··· 1238 1273 const body = await req.json(); 1239 1274 const { email_notifications_enabled } = body; 1240 1275 if (typeof email_notifications_enabled !== "boolean") { 1241 - return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 }); 1276 + return Response.json( 1277 + { error: "email_notifications_enabled must be a boolean" }, 1278 + { status: 400 }, 1279 + ); 1242 1280 } 1243 1281 try { 1244 - db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]); 1282 + db.run( 1283 + "UPDATE users SET email_notifications_enabled = ? WHERE id = ?", 1284 + [email_notifications_enabled ? 1 : 0, user.id], 1285 + ); 1245 1286 return Response.json({ success: true }); 1246 1287 } catch { 1247 1288 return Response.json( ··· 1491 1532 const transcriptionId = req.params.id; 1492 1533 // Verify ownership 1493 1534 const transcription = db 1494 - .query<{ id: string; user_id: number; class_id: string | null; status: string }, [string]>( 1535 + .query< 1536 + { 1537 + id: string; 1538 + user_id: number; 1539 + class_id: string | null; 1540 + status: string; 1541 + }, 1542 + [string] 1543 + >( 1495 1544 "SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?", 1496 1545 ) 1497 1546 .get(transcriptionId); 1498 - 1547 + 1499 1548 if (!transcription) { 1500 1549 return Response.json( 1501 1550 { error: "Transcription not found" }, ··· 1510 1559 1511 1560 // If transcription belongs to a class, check enrollment 1512 1561 if (transcription.class_id) { 1513 - isClassMember = isUserEnrolledInClass(user.id, transcription.class_id); 1562 + isClassMember = isUserEnrolledInClass( 1563 + user.id, 1564 + transcription.class_id, 1565 + ); 1514 1566 } 1515 1567 1516 1568 // Allow access if: owner, admin, or enrolled in the class ··· 1522 1574 } 1523 1575 1524 1576 // Require subscription only if accessing own transcription (not class) 1525 - if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) { 1577 + if ( 1578 + isOwner && 1579 + !transcription.class_id && 1580 + !isAdmin && 1581 + !hasActiveSubscription(user.id) 1582 + ) { 1526 1583 throw AuthErrors.subscriptionRequired(); 1527 1584 } 1528 1585 // Event-driven SSE stream with reconnection support ··· 1677 1734 1678 1735 // If transcription belongs to a class, check enrollment 1679 1736 if (transcription.class_id) { 1680 - isClassMember = isUserEnrolledInClass(user.id, transcription.class_id); 1737 + isClassMember = isUserEnrolledInClass( 1738 + user.id, 1739 + transcription.class_id, 1740 + ); 1681 1741 } 1682 1742 1683 1743 // Allow access if: owner, admin, or enrolled in the class ··· 1689 1749 } 1690 1750 1691 1751 // Require subscription only if accessing own transcription (not class) 1692 - if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) { 1752 + if ( 1753 + isOwner && 1754 + !transcription.class_id && 1755 + !isAdmin && 1756 + !hasActiveSubscription(user.id) 1757 + ) { 1693 1758 throw AuthErrors.subscriptionRequired(); 1694 1759 } 1695 1760 ··· 1777 1842 1778 1843 // If transcription belongs to a class, check enrollment 1779 1844 if (transcription.class_id) { 1780 - isClassMember = isUserEnrolledInClass(user.id, transcription.class_id); 1845 + isClassMember = isUserEnrolledInClass( 1846 + user.id, 1847 + transcription.class_id, 1848 + ); 1781 1849 } 1782 1850 1783 1851 // Allow access if: owner, admin, or enrolled in the class ··· 1789 1857 } 1790 1858 1791 1859 // Require subscription only if accessing own transcription (not class) 1792 - if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) { 1860 + if ( 1861 + isOwner && 1862 + !transcription.class_id && 1863 + !isAdmin && 1864 + !hasActiveSubscription(user.id) 1865 + ) { 1793 1866 throw AuthErrors.subscriptionRequired(); 1794 1867 } 1795 1868 ··· 2165 2238 .get(userId); 2166 2239 2167 2240 if (!user) { 2168 - return Response.json( 2169 - { error: "User not found" }, 2170 - { status: 404 }, 2171 - ); 2241 + return Response.json({ error: "User not found" }, { status: 404 }); 2172 2242 } 2173 2243 2174 2244 try { ··· 2304 2374 }), 2305 2375 }); 2306 2376 2307 - return Response.json({ 2377 + return Response.json({ 2308 2378 success: true, 2309 - message: "Password reset email sent" 2379 + message: "Password reset email sent", 2310 2380 }); 2311 2381 } catch (error) { 2312 2382 console.error("[Admin] Password reset error:", error); ··· 2367 2437 } 2368 2438 2369 2439 const body = await req.json(); 2370 - const { email, skipVerification } = body as { email: string; skipVerification?: boolean }; 2440 + const { email, skipVerification } = body as { 2441 + email: string; 2442 + skipVerification?: boolean; 2443 + }; 2371 2444 2372 2445 if (!email || !email.includes("@")) { 2373 2446 return Response.json( ··· 2393 2466 if (skipVerification) { 2394 2467 // Admin override: change email immediately without verification 2395 2468 updateUserEmailAddress(userId, email); 2396 - return Response.json({ 2469 + return Response.json({ 2397 2470 success: true, 2398 - message: "Email updated immediately (verification skipped)" 2471 + message: "Email updated immediately (verification skipped)", 2399 2472 }); 2400 2473 } 2401 2474
+26 -24
src/lib/auth.ts
··· 190 190 191 191 // Get user's subscription if they have one 192 192 const subscription = db 193 - .query<{ id: string; status: string; cancel_at_period_end: number }, [number]>( 193 + .query< 194 + { id: string; status: string; cancel_at_period_end: number }, 195 + [number] 196 + >( 194 197 "SELECT id, status, cancel_at_period_end FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 195 198 ) 196 199 .get(userId); 197 200 198 201 // Cancel subscription if it exists and is not already canceled or scheduled to cancel 199 202 if ( 200 - subscription && 201 - subscription.status !== 'canceled' && 202 - subscription.status !== 'expired' && 203 + subscription && 204 + subscription.status !== "canceled" && 205 + subscription.status !== "expired" && 203 206 !subscription.cancel_at_period_end 204 207 ) { 205 208 try { ··· 230 233 "UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL", 231 234 [userId], 232 235 ); 233 - db.run( 234 - "DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", 235 - [userId], 236 - ); 236 + db.run("DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", [ 237 + userId, 238 + ]); 237 239 238 240 // Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members) 239 241 db.run("DELETE FROM users WHERE id = ?", [userId]); ··· 266 268 * Email verification functions 267 269 */ 268 270 269 - export function createEmailVerificationToken(userId: number): { code: string; token: string; sentAt: number } { 271 + export function createEmailVerificationToken(userId: number): { 272 + code: string; 273 + token: string; 274 + sentAt: number; 275 + } { 270 276 // Generate a 6-digit code for user to enter 271 277 const code = Math.floor(100000 + Math.random() * 900000).toString(); 272 278 const id = crypto.randomUUID(); ··· 282 288 "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)", 283 289 [id, userId, code, expiresAt], 284 290 ); 285 - 291 + 286 292 // Store the URL token as a separate entry 287 293 db.run( 288 294 "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)", ··· 298 304 const now = Math.floor(Date.now() / 1000); 299 305 300 306 const result = db 301 - .query< 302 - { user_id: number; email: string }, 303 - [string, number] 304 - >( 307 + .query<{ user_id: number; email: string }, [string, number]>( 305 308 `SELECT evt.user_id, u.email 306 309 FROM email_verification_tokens evt 307 310 JOIN users u ON evt.user_id = u.id ··· 320 323 return { userId: result.user_id, email: result.email }; 321 324 } 322 325 323 - export function verifyEmailCode( 324 - userId: number, 325 - code: string, 326 - ): boolean { 326 + export function verifyEmailCode(userId: number, code: string): boolean { 327 327 const now = Math.floor(Date.now() / 1000); 328 328 329 329 const result = db 330 - .query< 331 - { user_id: number }, 332 - [number, string, number] 333 - >( 330 + .query<{ user_id: number }, [number, string, number]>( 334 331 `SELECT user_id 335 332 FROM email_verification_tokens 336 333 WHERE user_id = ? AND token = ? AND expires_at > ?`, ··· 408 405 * Email change functions 409 406 */ 410 407 411 - export function createEmailChangeToken(userId: number, newEmail: string): string { 408 + export function createEmailChangeToken( 409 + userId: number, 410 + newEmail: string, 411 + ): string { 412 412 const token = crypto.randomUUID(); 413 413 const id = crypto.randomUUID(); 414 414 const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours ··· 424 424 return token; 425 425 } 426 426 427 - export function verifyEmailChangeToken(token: string): { userId: number; newEmail: string } | null { 427 + export function verifyEmailChangeToken( 428 + token: string, 429 + ): { userId: number; newEmail: string } | null { 428 430 const now = Math.floor(Date.now() / 1000); 429 431 430 432 const result = db
-1
src/lib/client-auth.ts
··· 66 66 const hashArray = Array.from(hashBuffer); 67 67 return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 68 68 } 69 -
+1 -2
src/lib/crypto-fallback.ts
··· 23 23 24 24 const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); 25 25 const ch = (x: number, y: number, z: number) => (x & y) ^ (~x & z); 26 - const maj = (x: number, y: number, z: number) => 27 - (x & y) ^ (x & z) ^ (y & z); 26 + const maj = (x: number, y: number, z: number) => (x & y) ^ (x & z) ^ (y & z); 28 27 const s0 = (x: number) => rotr(x, 2) ^ rotr(x, 13) ^ rotr(x, 22); 29 28 const s1 = (x: number) => rotr(x, 6) ^ rotr(x, 11) ^ rotr(x, 25); 30 29 const g0 = (x: number) => rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
+31 -11
src/lib/email-change.test.ts
··· 1 - import { test, expect } from "bun:test"; 1 + import { expect, test } from "bun:test"; 2 2 import db from "../db/schema"; 3 3 import { 4 + consumeEmailChangeToken, 5 + createEmailChangeToken, 4 6 createUser, 5 - createEmailChangeToken, 7 + getUserByEmail, 8 + updateUserEmail, 6 9 verifyEmailChangeToken, 7 - consumeEmailChangeToken, 8 - updateUserEmail, 9 - getUserByEmail, 10 10 } from "./auth"; 11 11 12 12 test("email change token lifecycle", async () => { 13 13 // Create a test user with unique email 14 14 const timestamp = Date.now(); 15 - const user = await createUser(`test-email-change-${timestamp}@example.com`, "password123", "Test User"); 15 + const user = await createUser( 16 + `test-email-change-${timestamp}@example.com`, 17 + "password123", 18 + "Test User", 19 + ); 16 20 17 21 // Create an email change token 18 22 const newEmail = `new-email-${timestamp}@example.com`; ··· 28 32 expect(result?.newEmail).toBe(newEmail); 29 33 30 34 // Update the email 31 - updateUserEmail(result!.userId, result!.newEmail); 35 + if (result) { 36 + updateUserEmail(result.userId, result.newEmail); 37 + } 32 38 33 39 // Consume the token 34 40 consumeEmailChangeToken(token); ··· 50 56 test("email change token expires", async () => { 51 57 // Create a test user with unique email 52 58 const timestamp = Date.now(); 53 - const user = await createUser(`test-expire-${timestamp}@example.com`, "password123", "Test User"); 59 + const user = await createUser( 60 + `test-expire-${timestamp}@example.com`, 61 + "password123", 62 + "Test User", 63 + ); 54 64 55 65 // Create an email change token 56 66 const newEmail = `new-expire-${timestamp}@example.com`; ··· 73 83 test("only one email change token per user", async () => { 74 84 // Create a test user with unique email 75 85 const timestamp = Date.now(); 76 - const user = await createUser(`test-single-token-${timestamp}@example.com`, "password123", "Test User"); 86 + const user = await createUser( 87 + `test-single-token-${timestamp}@example.com`, 88 + "password123", 89 + "Test User", 90 + ); 77 91 78 92 // Create first token 79 - const token1 = createEmailChangeToken(user.id, `email1-${timestamp}@example.com`); 93 + const token1 = createEmailChangeToken( 94 + user.id, 95 + `email1-${timestamp}@example.com`, 96 + ); 80 97 81 98 // Create second token (should delete first) 82 - const token2 = createEmailChangeToken(user.id, `email2-${timestamp}@example.com`); 99 + const token2 = createEmailChangeToken( 100 + user.id, 101 + `email2-${timestamp}@example.com`, 102 + ); 83 103 84 104 // First token should be invalid 85 105 const result1 = verifyEmailChangeToken(token1);
+6 -3
src/lib/email-templates.ts
··· 210 210 <p>Your transcription is ready!</p> 211 211 212 212 <div class="info-box"> 213 - ${options.className ? ` 213 + ${ 214 + options.className 215 + ? ` 214 216 <p class="info-box-label">Class</p> 215 217 <p class="info-box-value">${options.className}</p> 216 218 <hr class="info-box-divider"> 217 - ` : ''} 219 + ` 220 + : "" 221 + } 218 222 <p class="info-box-label">File</p> 219 223 <p class="info-box-value">${options.originalFilename}</p> 220 224 </div> ··· 291 295 </html> 292 296 `.trim(); 293 297 } 294 -
+10 -12
src/lib/email-verification.test.ts
··· 1 - import { describe, test, expect, beforeEach, afterEach } from "bun:test"; 1 + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; 2 2 import db from "../db/schema"; 3 3 import { 4 - createUser, 4 + consumePasswordResetToken, 5 5 createEmailVerificationToken, 6 - verifyEmailToken, 6 + createPasswordResetToken, 7 + createUser, 7 8 isEmailVerified, 8 - createPasswordResetToken, 9 + verifyEmailToken, 9 10 verifyPasswordResetToken, 10 - consumePasswordResetToken, 11 11 } from "./auth"; 12 12 13 13 describe("Email Verification", () => { ··· 23 23 afterEach(() => { 24 24 // Cleanup 25 25 db.run("DELETE FROM users WHERE email = ?", [testEmail]); 26 - db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [ 27 - userId, 28 - ]); 26 + db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]); 29 27 }); 30 28 31 29 test("creates verification token", () => { ··· 138 136 const token = createPasswordResetToken(userId); 139 137 140 138 // 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 - ); 139 + db.run("UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?", [ 140 + Math.floor(Date.now() / 1000) - 100, 141 + token, 142 + ]); 145 143 146 144 const verifiedUserId = verifyPasswordResetToken(token); 147 145 expect(verifiedUserId).toBeNull();
+12 -10
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 { 112 + export function clearRateLimit( 113 + endpoint: string, 114 + email?: string, 115 + ipAddress?: string, 116 + ): void { 113 117 // Clear account-based rate limits 114 118 if (email) { 115 - db.run( 116 - "DELETE FROM rate_limit_attempts WHERE key = ?", 117 - [`${endpoint}:account:${email.toLowerCase()}`] 118 - ); 119 + db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [ 120 + `${endpoint}:account:${email.toLowerCase()}`, 121 + ]); 119 122 } 120 - 123 + 121 124 // Clear IP-based rate limits 122 125 if (ipAddress) { 123 - db.run( 124 - "DELETE FROM rate_limit_attempts WHERE key = ?", 125 - [`${endpoint}:ip:${ipAddress}`] 126 - ); 126 + db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [ 127 + `${endpoint}:ip:${ipAddress}`, 128 + ]); 127 129 } 128 130 } 129 131
+3 -6
src/lib/transcription.ts
··· 502 502 503 503 private async deleteWhisperJob(jobId: string) { 504 504 try { 505 - const response = await fetch( 506 - `${this.serviceUrl}/transcribe/${jobId}`, 507 - { 508 - method: "DELETE", 509 - }, 510 - ); 505 + const response = await fetch(`${this.serviceUrl}/transcribe/${jobId}`, { 506 + method: "DELETE", 507 + }); 511 508 if (response.ok) { 512 509 console.log(`[Cleanup] Deleted job ${jobId} from Murmur`); 513 510 } else {