tangled
alpha
login
or
join now
dunkirk.sh
/
thistle
1
fork
atom
🪻 distributed transcription service
thistle.dunkirk.sh
1
fork
atom
overview
issues
pulls
pipelines
chore: biome format
dunkirk.sh
3 months ago
0e3ccd2d
c734cd5f
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+556
-304
23 changed files
expand all
collapse all
unified
split
package.json
public
favicon
site.webmanifest
scripts
clear-rate-limits.ts
send-test-emails.ts
src
components
admin-classes.ts
admin-pending-recordings.ts
admin-transcriptions.ts
admin-users.ts
auth.ts
class-view.ts
reset-password-form.ts
transcription.ts
user-modal.ts
user-settings.ts
index.ts
lib
auth.ts
client-auth.ts
crypto-fallback.ts
email-change.test.ts
email-templates.ts
email-verification.test.ts
rate-limit.ts
transcription.ts
+28
-28
package.json
···
1
1
{
2
2
-
"name": "thistle",
3
3
-
"module": "src/index.ts",
4
4
-
"type": "module",
5
5
-
"private": true,
6
6
-
"scripts": {
7
7
-
"dev": "bun run src/index.ts --hot",
8
8
-
"clean": "rm -rf transcripts uploads thistle.db",
9
9
-
"test": "bun test",
10
10
-
"test:integration": "bun test src/index.test.ts",
11
11
-
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
12
12
-
},
13
13
-
"devDependencies": {
14
14
-
"@biomejs/biome": "^2.3.2",
15
15
-
"@simplewebauthn/types": "^12.0.0",
16
16
-
"@types/bun": "latest"
17
17
-
},
18
18
-
"peerDependencies": {
19
19
-
"typescript": "^5"
20
20
-
},
21
21
-
"dependencies": {
22
22
-
"@polar-sh/sdk": "^0.41.5",
23
23
-
"@simplewebauthn/browser": "^13.2.2",
24
24
-
"@simplewebauthn/server": "^13.2.2",
25
25
-
"eventsource-client": "^1.2.0",
26
26
-
"lit": "^3.3.1",
27
27
-
"nanoid": "^5.1.6",
28
28
-
"ua-parser-js": "^2.0.6"
29
29
-
}
2
2
+
"name": "thistle",
3
3
+
"module": "src/index.ts",
4
4
+
"type": "module",
5
5
+
"private": true,
6
6
+
"scripts": {
7
7
+
"dev": "bun run src/index.ts --hot",
8
8
+
"clean": "rm -rf transcripts uploads thistle.db",
9
9
+
"test": "bun test",
10
10
+
"test:integration": "bun test src/index.test.ts",
11
11
+
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
12
12
+
},
13
13
+
"devDependencies": {
14
14
+
"@biomejs/biome": "^2.3.2",
15
15
+
"@simplewebauthn/types": "^12.0.0",
16
16
+
"@types/bun": "latest"
17
17
+
},
18
18
+
"peerDependencies": {
19
19
+
"typescript": "^5"
20
20
+
},
21
21
+
"dependencies": {
22
22
+
"@polar-sh/sdk": "^0.41.5",
23
23
+
"@simplewebauthn/browser": "^13.2.2",
24
24
+
"@simplewebauthn/server": "^13.2.2",
25
25
+
"eventsource-client": "^1.2.0",
26
26
+
"lit": "^3.3.1",
27
27
+
"nanoid": "^5.1.6",
28
28
+
"ua-parser-js": "^2.0.6"
29
29
+
}
30
30
}
+19
-1
public/favicon/site.webmanifest
···
1
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
1
+
{
2
2
+
"name": "",
3
3
+
"short_name": "",
4
4
+
"icons": [
5
5
+
{
6
6
+
"src": "/android-chrome-192x192.png",
7
7
+
"sizes": "192x192",
8
8
+
"type": "image/png"
9
9
+
},
10
10
+
{
11
11
+
"src": "/android-chrome-512x512.png",
12
12
+
"sizes": "512x512",
13
13
+
"type": "image/png"
14
14
+
}
15
15
+
],
16
16
+
"theme_color": "#ffffff",
17
17
+
"background_color": "#ffffff",
18
18
+
"display": "standalone"
19
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
14
-
console.log(`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? '' : 's'}`);
14
14
+
console.log(
15
15
+
`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? "" : "s"}`,
16
16
+
);
15
17
}
+1
-1
scripts/send-test-emails.ts
···
5
5
6
6
import { sendEmail } from "../src/lib/email";
7
7
import {
8
8
-
verifyEmailTemplate,
9
8
passwordResetTemplate,
10
9
transcriptionCompleteTemplate,
10
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
-
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
-
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
529
-
const classToToggle = this.classes.find(c => c.id === classId);
529
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
543
-
this.classes = this.classes.map(c =>
544
544
-
c.id === classId ? { ...c, archived: !c.archived } : c
543
543
+
this.classes = this.classes.map((c) =>
544
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
249
-
try {
250
250
-
// Get all classes with their transcriptions
251
251
-
const response = await fetch("/api/classes");
252
252
-
if (!response.ok) {
253
253
-
const data = await response.json();
254
254
-
throw new Error(data.error || "Failed to load classes");
255
255
-
}
249
249
+
try {
250
250
+
// Get all classes with their transcriptions
251
251
+
const response = await fetch("/api/classes");
252
252
+
if (!response.ok) {
253
253
+
const data = await response.json();
254
254
+
throw new Error(data.error || "Failed to load classes");
255
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
320
-
this.error = err instanceof Error ? err.message : "Failed to load pending recordings. Please try again.";
320
320
+
this.error =
321
321
+
err instanceof Error
322
322
+
? err.message
323
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
341
-
this.error = err instanceof Error ? err.message : "Failed to approve recording. Please try again.";
344
344
+
this.error =
345
345
+
err instanceof Error
346
346
+
? err.message
347
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
368
-
this.error = err instanceof Error ? err.message : "Failed to delete recording. Please try again.";
374
374
+
this.error =
375
375
+
err instanceof Error
376
376
+
? err.message
377
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
199
-
this.error = err instanceof Error ? err.message : "Failed to load transcriptions. Please try again.";
199
199
+
this.error =
200
200
+
err instanceof Error
201
201
+
? err.message
202
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
231
-
this.error = err instanceof Error ? err.message : "Failed to delete transcription. Please try again.";
234
234
+
this.error =
235
235
+
err instanceof Error
236
236
+
? err.message
237
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
329
-
this.error = err instanceof Error ? err.message : "Failed to load users. Please try again.";
329
329
+
this.error =
330
330
+
err instanceof Error
331
331
+
? err.message
332
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
392
-
this.error = err instanceof Error ? err.message : "Failed to update user role";
395
395
+
this.error =
396
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
463
-
this.users = this.users.filter(u => u.id !== userId);
467
467
+
this.users = this.users.filter((u) => u.id !== userId);
464
468
this.dispatchEvent(new CustomEvent("user-deleted"));
465
469
} catch (err) {
466
466
-
this.error = err instanceof Error ? err.message : "Failed to delete user. Please try again.";
470
470
+
this.error =
471
471
+
err instanceof Error
472
472
+
? err.message
473
473
+
: "Failed to delete user. Please try again.";
467
474
}
468
475
}
469
476
470
470
-
private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {
477
477
+
private handleRevokeClick(
478
478
+
userId: number,
479
479
+
email: string,
480
480
+
subscriptionId: string,
481
481
+
event: Event,
482
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
513
-
this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout };
525
525
+
this.deleteState = {
526
526
+
id: userId,
527
527
+
type: "revoke",
528
528
+
clicks: newClicks,
529
529
+
timeout,
530
530
+
};
514
531
}
515
532
516
516
-
private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) {
533
533
+
private async performRevokeSubscription(
534
534
+
userId: number,
535
535
+
_email: string,
536
536
+
subscriptionId: string,
537
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
535
-
this.error = err instanceof Error ? err.message : "Failed to revoke subscription";
556
556
+
this.error =
557
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
594
-
616
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
619
-
641
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
626
-
648
648
+
627
649
// Hide ghost user unless specifically searched for
628
628
-
if (!query.includes("deleted") && !query.includes("ghost") && !query.includes("system")) {
629
629
-
filtered = filtered.filter(u => u.id !== 0);
650
650
+
if (
651
651
+
!query.includes("deleted") &&
652
652
+
!query.includes("ghost") &&
653
653
+
!query.includes("system")
654
654
+
) {
655
655
+
filtered = filtered.filter((u) => u.id !== 0);
630
656
}
631
631
-
657
657
+
632
658
return filtered;
633
659
}
634
660
···
666
692
<div class="users-grid">
667
693
${filtered.map(
668
694
(u) => html`
669
669
-
<div class="user-card ${u.id === 0 ? 'system' : ''}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
695
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
682
-
${u.id === 0
683
683
-
? html`<span class="system-badge">System</span>`
684
684
-
: u.role === "admin"
685
685
-
? html`<span class="admin-badge">Admin</span>`
686
686
-
: ""
687
687
-
}
708
708
+
${
709
709
+
u.id === 0
710
710
+
? html`<span class="system-badge">System</span>`
711
711
+
: u.role === "admin"
712
712
+
? html`<span class="admin-badge">Admin</span>`
713
713
+
: ""
714
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
698
-
${u.subscription_status
699
699
-
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
700
700
-
: html`<span class="subscription-badge none">None</span>`
701
701
-
}
725
725
+
${
726
726
+
u.subscription_status
727
727
+
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
728
728
+
: html`<span class="subscription-badge none">None</span>`
729
729
+
}
702
730
</div>
703
731
</div>
704
732
<div class="meta-item">
···
716
744
</div>
717
745
718
746
<div class="actions">
719
719
-
${u.id === 0
720
720
-
? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
721
721
-
: html`
747
747
+
${
748
748
+
u.id === 0
749
749
+
? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
750
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
743
-
this.handleRevokeClick(u.id, u.email, u.subscription_id, e);
772
772
+
this.handleRevokeClick(
773
773
+
u.id,
774
774
+
u.email,
775
775
+
u.subscription_id,
776
776
+
e,
777
777
+
);
744
778
}
745
779
}}
746
780
>
···
750
784
${this.getDeleteButtonText(u.id, "user")}
751
785
</button>
752
786
`
753
753
-
}
787
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
-
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
-
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
-
611
611
+
612
612
// Clear existing interval if any
613
613
if (this.resendInterval !== null) {
614
614
clearInterval(this.resendInterval);
615
615
}
616
616
-
616
616
+
617
617
// Update timer based on elapsed time
618
618
const updateTimer = () => {
619
619
if (this.codeSentAt === null) return;
620
620
-
620
620
+
621
621
const now = Math.floor(Date.now() / 1000);
622
622
const elapsed = now - this.codeSentAt;
623
623
-
const remaining = Math.max(0, (5 * 60) - elapsed);
623
623
+
const remaining = Math.max(0, 5 * 60 - elapsed);
624
624
this.resendCodeTimer = remaining;
625
625
-
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
-
633
633
+
634
634
// Update immediately
635
635
updateTimer();
636
636
-
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
674
-
return `${mins}:${secs.toString().padStart(2, '0')}`;
674
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
541
-
${!canAccessTranscriptions ? html`
541
541
+
${
542
542
+
!canAccessTranscriptions
543
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
547
-
` : html`
549
549
+
`
550
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
575
-
: html`
578
578
+
: html`
576
579
${this.filteredTranscriptions.map(
577
577
-
(t) => html`
580
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
620
-
)}
623
623
+
)}
621
624
`
622
622
-
}
623
623
-
`}
625
625
+
}
626
626
+
`
627
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
-
158
158
+
159
159
// When token property changes and we don't have email yet, load it
160
160
-
if (changedProperties.has('token') && this.token && !this.email && !this.isLoadingEmail) {
160
160
+
if (
161
161
+
changedProperties.has("token") &&
162
162
+
this.token &&
163
163
+
!this.email &&
164
164
+
!this.isLoadingEmail
165
165
+
) {
161
166
await this.loadEmail();
162
167
}
163
168
}
···
177
182
178
183
this.email = data.email;
179
184
} catch (err) {
180
180
-
this.error = err instanceof Error ? err.message : "Failed to verify reset token";
185
185
+
this.error =
186
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
233
-
${this.error
234
234
-
? html`<div class="error-banner">${this.error}</div>`
235
235
-
: ""}
239
239
+
${
240
240
+
this.error
241
241
+
? html`<div class="error-banner">${this.error}</div>`
242
242
+
: ""
243
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
301
-
const hashedPassword = await hashPasswordClient(this.password, this.email);
309
309
+
const hashedPassword = await hashPasswordClient(
310
310
+
this.password,
311
311
+
this.email,
312
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
795
-
const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin);
795
795
+
const canUpload =
796
796
+
this.serviceAvailable && (this.hasSubscription || this.isAdmin);
796
797
797
798
return html`
798
798
-
${!this.hasSubscription && !this.isAdmin ? html`
799
799
+
${
800
800
+
!this.hasSubscription && !this.isAdmin
801
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
804
-
` : ''}
807
807
+
`
808
808
+
: ""
809
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
413
-
const checkbox = form.querySelector('input[type="checkbox"]') as HTMLInputElement;
413
413
+
const checkbox = form.querySelector(
414
414
+
'input[type="checkbox"]',
415
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
473
-
const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, {
474
474
-
method: "POST",
475
475
-
headers: { "Content-Type": "application/json" },
476
476
-
});
475
475
+
const res = await fetch(
476
476
+
`/api/admin/users/${this.userId}/password-reset`,
477
477
+
{
478
478
+
method: "POST",
479
479
+
headers: { "Content-Type": "application/json" },
480
480
+
},
481
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
487
-
this.error = err instanceof Error ? err.message : "Failed to send password reset email";
492
492
+
this.error =
493
493
+
err instanceof Error
494
494
+
? err.message
495
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
39
-
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger";
39
39
+
type SettingsPage =
40
40
+
| "account"
41
41
+
| "sessions"
42
42
+
| "passkeys"
43
43
+
| "billing"
44
44
+
| "notifications"
45
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
547
-
553
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
554
-
560
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
564
-
return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab);
570
570
+
return [
571
571
+
"account",
572
572
+
"sessions",
573
573
+
"passkeys",
574
574
+
"billing",
575
575
+
"notifications",
576
576
+
"danger",
577
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
677
-
this.error = err instanceof Error ? err.message : "Failed to delete passkey";
690
690
+
this.error =
691
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
685
-
699
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
691
-
705
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
702
-
716
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
1026
-
${this.error ? html`
1040
1040
+
${
1041
1041
+
this.error
1042
1042
+
? html`
1027
1043
<div class="error-banner">
1028
1044
${this.error}
1029
1045
</div>
1030
1030
-
` : ""}
1046
1046
+
`
1047
1047
+
: ""
1048
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
1072
-
${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ''}
1090
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
1079
-
? html`
1097
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
1095
-
${this.updatingEmail ? html`<span class="spinner"></span>` : 'Save'}
1113
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
1108
-
: html`
1126
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
1251
-
${this.error ? html`
1269
1269
+
${
1270
1270
+
this.error
1271
1271
+
? html`
1252
1272
<div class="error-banner">
1253
1273
${this.error}
1254
1274
</div>
1255
1255
-
` : ""}
1275
1275
+
`
1276
1276
+
: ""
1277
1277
+
}
1256
1278
<div class="section">
1257
1279
<h2 class="section-title">Active Sessions</h2>
1258
1280
${
···
1330
1352
`;
1331
1353
}
1332
1354
1333
1333
-
const hasActiveSubscription = this.subscription && (
1334
1334
-
this.subscription.status === "active" ||
1335
1335
-
this.subscription.status === "trialing"
1336
1336
-
);
1355
1355
+
const hasActiveSubscription =
1356
1356
+
this.subscription &&
1357
1357
+
(this.subscription.status === "active" ||
1358
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
1340
-
const statusColor =
1341
1341
-
this.subscription.status === "canceled" ? "var(--accent)" :
1342
1342
-
"var(--secondary)";
1362
1362
+
const statusColor =
1363
1363
+
this.subscription.status === "canceled"
1364
1364
+
? "var(--accent)"
1365
1365
+
: "var(--secondary)";
1343
1366
1344
1367
return html`
1345
1368
<div class="content-inner">
1346
1346
-
${this.error ? html`
1369
1369
+
${
1370
1370
+
this.error
1371
1371
+
? html`
1347
1372
<div class="error-banner">
1348
1373
${this.error}
1349
1374
</div>
1350
1350
-
` : ""}
1375
1375
+
`
1376
1376
+
: ""
1377
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
1372
-
${this.subscription.canceled_at ? html`
1399
1399
+
${
1400
1400
+
this.subscription.canceled_at
1401
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
1379
-
` : ""}
1408
1408
+
`
1409
1409
+
: ""
1410
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
1401
-
${this.error ? html`
1432
1432
+
${
1433
1433
+
this.error
1434
1434
+
? html`
1402
1435
<div class="error-banner">
1403
1436
${this.error}
1404
1437
</div>
1405
1405
-
` : ""}
1438
1438
+
`
1439
1439
+
: ""
1440
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
1424
-
${this.subscription.cancel_at_period_end ? html`
1459
1459
+
${
1460
1460
+
this.subscription.cancel_at_period_end
1461
1461
+
? html`
1425
1462
<span style="color: var(--accent); font-size: 0.875rem;">
1426
1463
(Cancels at end of period)
1427
1464
</span>
1428
1428
-
` : ""}
1465
1465
+
`
1466
1466
+
: ""
1467
1467
+
}
1429
1468
</div>
1430
1469
</div>
1431
1470
1432
1432
-
${this.subscription.current_period_start && this.subscription.current_period_end ? html`
1471
1471
+
${
1472
1472
+
this.subscription.current_period_start &&
1473
1473
+
this.subscription.current_period_end
1474
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
1440
-
` : ""}
1482
1482
+
`
1483
1483
+
: ""
1484
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
1461
-
${this.error ? html`
1505
1505
+
${
1506
1506
+
this.error
1507
1507
+
? html`
1462
1508
<div class="error-banner">
1463
1509
${this.error}
1464
1510
</div>
1465
1465
-
` : ""}
1511
1511
+
`
1512
1512
+
: ""
1513
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
1486
-
${this.error ? html`
1534
1534
+
${
1535
1535
+
this.error
1536
1536
+
? html`
1487
1537
<div class="error-banner">
1488
1538
${this.error}
1489
1539
</div>
1490
1490
-
` : ""}
1540
1540
+
`
1541
1541
+
: ""
1542
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
1513
-
${this.error ? html`
1565
1565
+
${
1566
1566
+
this.error
1567
1567
+
? html`
1514
1568
<div class="error-banner">
1515
1569
${this.error}
1516
1570
</div>
1517
1517
-
` : ""}
1571
1571
+
`
1572
1572
+
: ""
1573
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
1539
-
1595
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
1545
-
email_notifications_enabled: this.emailNotificationsEnabled,
1601
1601
+
email_notifications_enabled:
1602
1602
+
this.emailNotificationsEnabled,
1546
1603
}),
1547
1604
});
1548
1548
-
1605
1605
+
1549
1606
if (!response.ok) {
1550
1607
const data = await response.json();
1551
1551
-
throw new Error(data.error || "Failed to update notification settings");
1608
1608
+
throw new Error(
1609
1609
+
data.error || "Failed to update notification settings",
1610
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
1557
-
this.error = err instanceof Error ? err.message : "Failed to update notification settings";
1616
1616
+
this.error =
1617
1617
+
err instanceof Error
1618
1618
+
? err.message
1619
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
5
+
consumeEmailChangeToken,
6
6
+
consumePasswordResetToken,
7
7
+
createEmailChangeToken,
8
8
+
createEmailVerificationToken,
9
9
+
createPasswordResetToken,
5
10
createSession,
6
11
createUser,
7
12
deleteAllUserSessions,
···
17
22
getUserByEmail,
18
23
getUserBySession,
19
24
getUserSessionsForUser,
25
25
+
getVerificationCodeSentAt,
26
26
+
isEmailVerified,
20
27
type UserRole,
21
28
updateUserAvatar,
22
29
updateUserEmail,
···
24
31
updateUserName,
25
32
updateUserPassword,
26
33
updateUserRole,
27
27
-
createEmailVerificationToken,
28
28
-
verifyEmailToken,
34
34
+
verifyEmailChangeToken,
29
35
verifyEmailCode,
30
30
-
isEmailVerified,
31
31
-
getVerificationCodeSentAt,
32
32
-
createPasswordResetToken,
36
36
+
verifyEmailToken,
33
37
verifyPasswordResetToken,
34
34
-
consumePasswordResetToken,
35
35
-
createEmailChangeToken,
36
36
-
verifyEmailChangeToken,
37
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
60
+
import { sendEmail } from "./lib/email";
61
61
+
import {
62
62
+
emailChangeTemplate,
63
63
+
passwordResetTemplate,
64
64
+
verifyEmailTemplate,
65
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
76
-
import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit";
82
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
84
-
import { sendEmail } from "./lib/email";
85
85
-
import {
86
86
-
verifyEmailTemplate,
87
87
-
passwordResetTemplate,
88
88
-
emailChangeTemplate,
89
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
147
-
if (!subscriptions.result.items || subscriptions.result.items.length === 0) {
147
147
+
if (
148
148
+
!subscriptions.result.items ||
149
149
+
subscriptions.result.items.length === 0
150
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
154
-
(sub) => sub.status === 'active' || sub.status === 'trialing' || sub.status === 'past_due'
157
157
+
(sub) =>
158
158
+
sub.status === "active" ||
159
159
+
sub.status === "trialing" ||
160
160
+
sub.status === "past_due",
155
161
);
156
162
157
163
if (currentSubscriptions.length === 0) {
158
158
-
console.log(`[Sync] No current subscriptions found for customer ${customer.id}`);
164
164
+
console.log(
165
165
+
`[Sync] No current subscriptions found for customer ${customer.id}`,
166
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
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
283
-
290
290
+
284
291
// Send verification email - MUST succeed for registration to complete
285
292
const { code, token, sentAt } = createEmailVerificationToken(user.id);
286
286
-
293
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
300
-
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]);
307
307
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
308
308
+
user.id,
309
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
304
-
{ error: "Failed to send verification email. Please try again later." },
313
313
+
{
314
314
+
error:
315
315
+
"Failed to send verification email. Please try again later.",
316
316
+
},
305
317
{ status: 500 },
306
318
);
307
319
}
308
308
-
320
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
313
-
325
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
320
-
332
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
324
-
{
336
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
380
-
392
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
387
-
399
399
+
388
400
// Check if email is verified
389
401
if (!isEmailVerified(user.id)) {
390
402
let codeSentAt = getVerificationCodeSentAt(user.id);
391
391
-
403
403
+
392
404
// If no verification code exists, auto-send one
393
405
if (!codeSentAt) {
394
394
-
const { code, token, sentAt } = createEmailVerificationToken(user.id);
406
406
+
const { code, token, sentAt } = createEmailVerificationToken(
407
407
+
user.id,
408
408
+
);
395
409
codeSentAt = sentAt;
396
396
-
410
410
+
397
411
try {
398
412
await sendEmail({
399
413
to: user.email,
···
405
419
}),
406
420
});
407
421
} catch (err) {
408
408
-
console.error("[Email] Failed to send verification email on login:", err);
422
422
+
console.error(
423
423
+
"[Email] Failed to send verification email on login:",
424
424
+
err,
425
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
413
-
430
430
+
414
431
return Response.json(
415
415
-
{
432
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
423
-
440
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
468
-
"Location": "/classes",
485
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
492
-
return Response.json(
493
493
-
{ error: "User not found" },
494
494
-
{ status: 404 },
495
495
-
);
509
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
524
-
{
538
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
544
-
558
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
589
-
const rateLimitError = enforceRateLimit(req, "resend-verification-code", {
590
590
-
account: { max: 3, windowSeconds: 5 * 60, email },
591
591
-
});
603
603
+
const rateLimitError = enforceRateLimit(
604
604
+
req,
605
605
+
"resend-verification-code",
606
606
+
{
607
607
+
account: { max: 3, windowSeconds: 5 * 60, email },
608
608
+
},
609
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
598
-
return Response.json({ message: "If an account exists with that email, a verification code has been sent" });
616
616
+
return Response.json({
617
617
+
message:
618
618
+
"If an account exists with that email, a verification code has been sent",
619
619
+
});
599
620
}
600
621
601
622
// Check if already verified
···
619
640
}),
620
641
});
621
642
622
622
-
return Response.json({
643
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
686
-
return Response.json(
687
687
-
{ error: "Token required" },
688
688
-
{ status: 400 },
689
689
-
);
707
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
702
-
.query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?")
720
720
+
.query<{ email: string }, [number]>(
721
721
+
"SELECT email FROM users WHERE id = ?",
722
722
+
)
703
723
.get(userId);
704
724
705
725
if (!user) {
···
1089
1109
}),
1090
1110
});
1091
1111
1092
1092
-
return Response.json({
1112
1112
+
return Response.json({
1093
1113
success: true,
1094
1114
message: `Verification email sent to ${user.email}`,
1095
1095
-
pendingEmail: email
1115
1115
+
pendingEmail: email,
1096
1116
});
1097
1117
} catch (error) {
1098
1098
-
console.error("[Email] Failed to send email change verification:", error);
1118
1118
+
console.error(
1119
1119
+
"[Email] Failed to send email change verification:",
1120
1120
+
error,
1121
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
1113
-
return Response.redirect("/settings?tab=account&error=invalid-token", 302);
1136
1136
+
return Response.redirect(
1137
1137
+
"/settings?tab=account&error=invalid-token",
1138
1138
+
302,
1139
1139
+
);
1114
1140
}
1115
1141
1116
1142
const result = verifyEmailChangeToken(token);
1117
1143
1118
1144
if (!result) {
1119
1119
-
return Response.redirect("/settings?tab=account&error=expired-token", 302);
1145
1145
+
return Response.redirect(
1146
1146
+
"/settings?tab=account&error=expired-token",
1147
1147
+
302,
1148
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
1129
-
return Response.redirect("/settings?tab=account&success=email-changed", 302);
1158
1158
+
return Response.redirect(
1159
1159
+
"/settings?tab=account&success=email-changed",
1160
1160
+
302,
1161
1161
+
);
1130
1162
} catch (error) {
1131
1163
console.error("[Email] Email change verification error:", error);
1132
1132
-
return Response.redirect("/settings?tab=account&error=verification-failed", 302);
1164
1164
+
return Response.redirect(
1165
1165
+
"/settings?tab=account&error=verification-failed",
1166
1166
+
302,
1167
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
1241
-
return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 });
1276
1276
+
return Response.json(
1277
1277
+
{ error: "email_notifications_enabled must be a boolean" },
1278
1278
+
{ status: 400 },
1279
1279
+
);
1242
1280
}
1243
1281
try {
1244
1244
-
db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]);
1282
1282
+
db.run(
1283
1283
+
"UPDATE users SET email_notifications_enabled = ? WHERE id = ?",
1284
1284
+
[email_notifications_enabled ? 1 : 0, user.id],
1285
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
1494
-
.query<{ id: string; user_id: number; class_id: string | null; status: string }, [string]>(
1535
1535
+
.query<
1536
1536
+
{
1537
1537
+
id: string;
1538
1538
+
user_id: number;
1539
1539
+
class_id: string | null;
1540
1540
+
status: string;
1541
1541
+
},
1542
1542
+
[string]
1543
1543
+
>(
1495
1544
"SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?",
1496
1545
)
1497
1546
.get(transcriptionId);
1498
1498
-
1547
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
1513
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
1562
1562
+
isClassMember = isUserEnrolledInClass(
1563
1563
+
user.id,
1564
1564
+
transcription.class_id,
1565
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
1525
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
1577
1577
+
if (
1578
1578
+
isOwner &&
1579
1579
+
!transcription.class_id &&
1580
1580
+
!isAdmin &&
1581
1581
+
!hasActiveSubscription(user.id)
1582
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
1680
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
1737
1737
+
isClassMember = isUserEnrolledInClass(
1738
1738
+
user.id,
1739
1739
+
transcription.class_id,
1740
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
1692
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
1752
1752
+
if (
1753
1753
+
isOwner &&
1754
1754
+
!transcription.class_id &&
1755
1755
+
!isAdmin &&
1756
1756
+
!hasActiveSubscription(user.id)
1757
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
1780
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
1845
1845
+
isClassMember = isUserEnrolledInClass(
1846
1846
+
user.id,
1847
1847
+
transcription.class_id,
1848
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
1792
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
1860
1860
+
if (
1861
1861
+
isOwner &&
1862
1862
+
!transcription.class_id &&
1863
1863
+
!isAdmin &&
1864
1864
+
!hasActiveSubscription(user.id)
1865
1865
+
) {
1793
1866
throw AuthErrors.subscriptionRequired();
1794
1867
}
1795
1868
···
2165
2238
.get(userId);
2166
2239
2167
2240
if (!user) {
2168
2168
-
return Response.json(
2169
2169
-
{ error: "User not found" },
2170
2170
-
{ status: 404 },
2171
2171
-
);
2241
2241
+
return Response.json({ error: "User not found" }, { status: 404 });
2172
2242
}
2173
2243
2174
2244
try {
···
2304
2374
}),
2305
2375
});
2306
2376
2307
2307
-
return Response.json({
2377
2377
+
return Response.json({
2308
2378
success: true,
2309
2309
-
message: "Password reset email sent"
2379
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
2370
-
const { email, skipVerification } = body as { email: string; skipVerification?: boolean };
2440
2440
+
const { email, skipVerification } = body as {
2441
2441
+
email: string;
2442
2442
+
skipVerification?: boolean;
2443
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
2396
-
return Response.json({
2469
2469
+
return Response.json({
2397
2470
success: true,
2398
2398
-
message: "Email updated immediately (verification skipped)"
2471
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
193
-
.query<{ id: string; status: string; cancel_at_period_end: number }, [number]>(
193
193
+
.query<
194
194
+
{ id: string; status: string; cancel_at_period_end: number },
195
195
+
[number]
196
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
200
-
subscription &&
201
201
-
subscription.status !== 'canceled' &&
202
202
-
subscription.status !== 'expired' &&
203
203
+
subscription &&
204
204
+
subscription.status !== "canceled" &&
205
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
233
-
db.run(
234
234
-
"DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL",
235
235
-
[userId],
236
236
-
);
236
236
+
db.run("DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", [
237
237
+
userId,
238
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
269
-
export function createEmailVerificationToken(userId: number): { code: string; token: string; sentAt: number } {
271
271
+
export function createEmailVerificationToken(userId: number): {
272
272
+
code: string;
273
273
+
token: string;
274
274
+
sentAt: number;
275
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
285
-
291
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
301
-
.query<
302
302
-
{ user_id: number; email: string },
303
303
-
[string, number]
304
304
-
>(
307
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
323
-
export function verifyEmailCode(
324
324
-
userId: number,
325
325
-
code: string,
326
326
-
): boolean {
326
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
330
-
.query<
331
331
-
{ user_id: number },
332
332
-
[number, string, number]
333
333
-
>(
330
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
411
-
export function createEmailChangeToken(userId: number, newEmail: string): string {
408
408
+
export function createEmailChangeToken(
409
409
+
userId: number,
410
410
+
newEmail: string,
411
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
427
-
export function verifyEmailChangeToken(token: string): { userId: number; newEmail: string } | null {
427
427
+
export function verifyEmailChangeToken(
428
428
+
token: string,
429
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
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
26
-
const maj = (x: number, y: number, z: number) =>
27
27
-
(x & y) ^ (x & z) ^ (y & z);
26
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
1
-
import { test, expect } from "bun:test";
1
1
+
import { expect, test } from "bun:test";
2
2
import db from "../db/schema";
3
3
import {
4
4
+
consumeEmailChangeToken,
5
5
+
createEmailChangeToken,
4
6
createUser,
5
5
-
createEmailChangeToken,
7
7
+
getUserByEmail,
8
8
+
updateUserEmail,
6
9
verifyEmailChangeToken,
7
7
-
consumeEmailChangeToken,
8
8
-
updateUserEmail,
9
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
15
-
const user = await createUser(`test-email-change-${timestamp}@example.com`, "password123", "Test User");
15
15
+
const user = await createUser(
16
16
+
`test-email-change-${timestamp}@example.com`,
17
17
+
"password123",
18
18
+
"Test User",
19
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
31
-
updateUserEmail(result!.userId, result!.newEmail);
35
35
+
if (result) {
36
36
+
updateUserEmail(result.userId, result.newEmail);
37
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
53
-
const user = await createUser(`test-expire-${timestamp}@example.com`, "password123", "Test User");
59
59
+
const user = await createUser(
60
60
+
`test-expire-${timestamp}@example.com`,
61
61
+
"password123",
62
62
+
"Test User",
63
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
76
-
const user = await createUser(`test-single-token-${timestamp}@example.com`, "password123", "Test User");
86
86
+
const user = await createUser(
87
87
+
`test-single-token-${timestamp}@example.com`,
88
88
+
"password123",
89
89
+
"Test User",
90
90
+
);
77
91
78
92
// Create first token
79
79
-
const token1 = createEmailChangeToken(user.id, `email1-${timestamp}@example.com`);
93
93
+
const token1 = createEmailChangeToken(
94
94
+
user.id,
95
95
+
`email1-${timestamp}@example.com`,
96
96
+
);
80
97
81
98
// Create second token (should delete first)
82
82
-
const token2 = createEmailChangeToken(user.id, `email2-${timestamp}@example.com`);
99
99
+
const token2 = createEmailChangeToken(
100
100
+
user.id,
101
101
+
`email2-${timestamp}@example.com`,
102
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
213
-
${options.className ? `
213
213
+
${
214
214
+
options.className
215
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
217
-
` : ''}
219
219
+
`
220
220
+
: ""
221
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
294
-
+10
-12
src/lib/email-verification.test.ts
···
1
1
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
1
1
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
import db from "../db/schema";
3
3
import {
4
4
-
createUser,
4
4
+
consumePasswordResetToken,
5
5
createEmailVerificationToken,
6
6
-
verifyEmailToken,
6
6
+
createPasswordResetToken,
7
7
+
createUser,
7
8
isEmailVerified,
8
8
-
createPasswordResetToken,
9
9
+
verifyEmailToken,
9
10
verifyPasswordResetToken,
10
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
26
-
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
27
27
-
userId,
28
28
-
]);
26
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
141
-
db.run(
142
142
-
"UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?",
143
143
-
[Math.floor(Date.now() / 1000) - 100, token],
144
144
-
);
139
139
+
db.run("UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?", [
140
140
+
Math.floor(Date.now() / 1000) - 100,
141
141
+
token,
142
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
112
-
export function clearRateLimit(endpoint: string, email?: string, ipAddress?: string): void {
112
112
+
export function clearRateLimit(
113
113
+
endpoint: string,
114
114
+
email?: string,
115
115
+
ipAddress?: string,
116
116
+
): void {
113
117
// Clear account-based rate limits
114
118
if (email) {
115
115
-
db.run(
116
116
-
"DELETE FROM rate_limit_attempts WHERE key = ?",
117
117
-
[`${endpoint}:account:${email.toLowerCase()}`]
118
118
-
);
119
119
+
db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [
120
120
+
`${endpoint}:account:${email.toLowerCase()}`,
121
121
+
]);
119
122
}
120
120
-
123
123
+
121
124
// Clear IP-based rate limits
122
125
if (ipAddress) {
123
123
-
db.run(
124
124
-
"DELETE FROM rate_limit_attempts WHERE key = ?",
125
125
-
[`${endpoint}:ip:${ipAddress}`]
126
126
-
);
126
126
+
db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [
127
127
+
`${endpoint}:ip:${ipAddress}`,
128
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
505
-
const response = await fetch(
506
506
-
`${this.serviceUrl}/transcribe/${jobId}`,
507
507
-
{
508
508
-
method: "DELETE",
509
509
-
},
510
510
-
);
505
505
+
const response = await fetch(`${this.serviceUrl}/transcribe/${jobId}`, {
506
506
+
method: "DELETE",
507
507
+
});
511
508
if (response.ok) {
512
509
console.log(`[Cleanup] Deleted job ${jobId} from Murmur`);
513
510
} else {