馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import { hashPasswordClient } from "../lib/client-auth";
4import {
5 authenticateWithPasskey,
6 isPasskeySupported,
7} from "../lib/client-passkey";
8import type { PasswordStrength } from "./password-strength";
9import "./password-strength";
10import type { PasswordStrengthResult } from "./password-strength";
11
12interface User {
13 email: string;
14 name: string | null;
15 avatar: string;
16 role?: "user" | "admin";
17 has_subscription?: boolean;
18}
19
20@customElement("auth-component")
21export class AuthComponent extends LitElement {
22 @state() user: User | null = null;
23 @state() loading = true;
24 @state() showModal = false;
25 @state() email = "";
26 @state() password = "";
27 @state() name = "";
28 @state() error = "";
29 @state() isSubmitting = false;
30 @state() needsRegistration = false;
31 @state() passwordStrength: PasswordStrengthResult | null = null;
32 @state() passkeySupported = false;
33 @state() needsEmailVerification = false;
34 @state() verificationCode = "";
35 @state() resendCodeTimer = 0;
36 @state() resendingCode = false;
37 private resendInterval: number | null = null;
38 private codeSentAt: number | null = null; // Unix timestamp in seconds when code was sent
39
40 static override styles = css`
41 :host {
42 display: block;
43 }
44
45 .auth-container {
46 position: relative;
47 }
48
49 .auth-button {
50 display: flex;
51 align-items: center;
52 gap: 0.5rem;
53 padding: 0.5rem 1rem;
54 background: var(--primary);
55 color: white;
56 border: 2px solid var(--primary);
57 border-radius: 8px;
58 cursor: pointer;
59 font-size: 1rem;
60 font-weight: 500;
61 transition: all 0.2s;
62 font-family: inherit;
63 }
64
65 .auth-button:hover {
66 background: transparent;
67 color: var(--primary);
68 }
69
70 .auth-button:hover .email {
71 color: var(--primary);
72 }
73
74 .auth-button img {
75 transition: all 0.2s;
76 }
77
78 .auth-button:hover img {
79 opacity: 0.8;
80 }
81
82 .user-info {
83 display: flex;
84 align-items: center;
85 gap: 0.75rem;
86 }
87
88 .email {
89 font-weight: 500;
90 color: white;
91 font-size: 0.875rem;
92 transition: all 0.2s;
93 }
94
95 .modal-overlay {
96 position: fixed;
97 top: 0;
98 left: 0;
99 right: 0;
100 bottom: 0;
101 background: rgba(0, 0, 0, 0.5);
102 display: flex;
103 align-items: center;
104 justify-content: center;
105 z-index: 2000;
106 padding: 1rem;
107 }
108
109 .modal {
110 background: var(--background);
111 border: 2px solid var(--secondary);
112 border-radius: 12px;
113 padding: 2rem;
114 max-width: 400px;
115 width: 100%;
116 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
117 }
118
119 .modal-title {
120 margin-top: 0;
121 margin-bottom: 1rem;
122 color: var(--text);
123 }
124
125 .form-group {
126 margin-bottom: 1rem;
127 }
128
129 label {
130 display: block;
131 margin-bottom: 0.25rem;
132 font-weight: 500;
133 color: var(--text);
134 font-size: 0.875rem;
135 }
136
137 input {
138 width: 100%;
139 padding: 0.75rem;
140 border: 2px solid var(--secondary);
141 border-radius: 6px;
142 font-size: 1rem;
143 font-family: inherit;
144 background: var(--background);
145 color: var(--text);
146 transition: all 0.2s;
147 box-sizing: border-box;
148 }
149
150 input::placeholder {
151 color: var(--secondary);
152 opacity: 1;
153 }
154
155 input:focus {
156 outline: none;
157 border-color: var(--primary);
158 }
159
160 .error-message {
161 color: var(--accent);
162 font-size: 0.875rem;
163 margin-top: 1rem;
164 }
165
166 button {
167 padding: 0.75rem 1.5rem;
168 border: 2px solid var(--primary);
169 border-radius: 6px;
170 font-size: 1rem;
171 font-weight: 500;
172 cursor: pointer;
173 transition: all 0.2s;
174 font-family: inherit;
175 }
176
177 button:disabled {
178 opacity: 0.6;
179 cursor: not-allowed;
180 }
181
182 .btn-primary {
183 background: var(--primary);
184 color: white;
185 flex: 1;
186 }
187
188 .btn-primary:hover:not(:disabled) {
189 background: transparent;
190 color: var(--primary);
191 }
192
193 .btn-neutral {
194 background: transparent;
195 color: var(--text);
196 border-color: var(--secondary);
197 }
198
199 .btn-neutral:hover:not(:disabled) {
200 border-color: var(--primary);
201 color: var(--primary);
202 }
203
204 .btn-rejection {
205 background: transparent;
206 color: var(--accent);
207 border-color: var(--accent);
208 }
209
210 .btn-rejection:hover:not(:disabled) {
211 background: var(--accent);
212 color: white;
213 }
214
215 .modal-actions {
216 display: flex;
217 gap: 0.5rem;
218 margin-top: 1rem;
219 }
220
221 .user-menu {
222 position: absolute;
223 top: calc(100% + 0.5rem);
224 right: 0;
225 background: var(--background);
226 border: 2px solid var(--secondary);
227 border-radius: 8px;
228 padding: 0.5rem;
229 min-width: 200px;
230 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
231 display: flex;
232 flex-direction: column;
233 gap: 0.5rem;
234 z-index: 100;
235 }
236
237 .user-menu a,
238 .user-menu button {
239 padding: 0.75rem 1rem;
240 background: transparent;
241 color: var(--text);
242 text-decoration: none;
243 border: none;
244 border-radius: 6px;
245 font-weight: 500;
246 text-align: left;
247 transition: all 0.2s;
248 font-family: inherit;
249 font-size: 1rem;
250 cursor: pointer;
251 }
252
253 .user-menu a:hover,
254 .user-menu button:hover {
255 background: var(--secondary);
256 }
257
258 .admin-link {
259 color: #dc2626;
260 border: 2px dashed #dc2626 !important;
261 }
262
263 .admin-link:hover {
264 background: #fee2e2;
265 color: #991b1b;
266 border-color: #991b1b !important;
267 }
268
269 .loading {
270 font-size: 0.875rem;
271 color: var(--text);
272 }
273
274 .info-text {
275 color: var(--text);
276 font-size: 0.875rem;
277 margin: 0 0 1.5rem 0;
278 line-height: 1.5;
279 }
280
281 .verification-code-input {
282 text-align: center;
283 font-size: 1.5rem;
284 letter-spacing: 0.5rem;
285 font-weight: 600;
286 padding: 1rem;
287 font-family: 'Monaco', 'Courier New', monospace;
288 }
289
290 .resend-link {
291 text-align: center;
292 margin-top: 1rem;
293 font-size: 0.875rem;
294 color: var(--text);
295 }
296
297 .resend-button {
298 background: none;
299 border: none;
300 color: var(--primary);
301 cursor: pointer;
302 text-decoration: underline;
303 font-size: 0.875rem;
304 padding: 0;
305 font-family: inherit;
306 }
307
308 .resend-button:hover:not(:disabled) {
309 color: var(--accent);
310 }
311
312 .resend-button:disabled {
313 color: var(--secondary);
314 cursor: not-allowed;
315 text-decoration: none;
316 }
317
318 .btn-secondary {
319 background: transparent;
320 color: var(--text);
321 border-color: var(--secondary);
322 flex: 1;
323 }
324
325 .btn-secondary:hover:not(:disabled) {
326 border-color: var(--primary);
327 color: var(--primary);
328 }
329
330 .divider {
331 display: flex;
332 align-items: center;
333 text-align: center;
334 margin: 1.5rem 0;
335 color: var(--secondary);
336 font-size: 0.875rem;
337 }
338
339 .divider::before,
340 .divider::after {
341 content: "";
342 flex: 1;
343 border-bottom: 1px solid var(--secondary);
344 }
345
346 .divider::before {
347 margin-right: 0.5rem;
348 }
349
350 .divider::after {
351 margin-left: 0.5rem;
352 }
353
354 .btn-passkey {
355 background: transparent;
356 color: var(--primary);
357 border-color: var(--primary);
358 width: 100%;
359 margin-bottom: 0;
360 }
361
362 .btn-passkey:hover:not(:disabled) {
363 background: var(--primary);
364 color: white;
365 }
366 `;
367
368 override async connectedCallback() {
369 super.connectedCallback();
370 this.passkeySupported = isPasskeySupported();
371 await this.checkAuth();
372 }
373
374 async checkAuth() {
375 try {
376 const response = await fetch("/api/auth/me");
377
378 if (response.ok) {
379 this.user = await response.json();
380 } else if (window.location.pathname !== "/") {
381 window.location.href = "/";
382 }
383 } finally {
384 this.loading = false;
385 }
386 }
387
388 public isAuthenticated(): boolean {
389 return this.user !== null;
390 }
391
392 public openAuthModal() {
393 this.openModal();
394 }
395
396 private openModal() {
397 this.showModal = true;
398 this.needsRegistration = false;
399 this.email = "";
400 this.password = "";
401 this.name = "";
402 this.error = "";
403 }
404
405 private closeModal() {
406 this.showModal = false;
407 this.needsRegistration = false;
408 this.email = "";
409 this.password = "";
410 this.name = "";
411 this.error = "";
412 }
413
414 private async handleSubmit(e: Event) {
415 e.preventDefault();
416 this.error = "";
417 this.isSubmitting = true;
418
419 try {
420 // Hash password client-side with expensive PBKDF2
421 const passwordHash = await hashPasswordClient(this.password, this.email);
422
423 if (this.needsRegistration) {
424 const response = await fetch("/api/auth/register", {
425 method: "POST",
426 headers: {
427 "Content-Type": "application/json",
428 },
429 body: JSON.stringify({
430 email: this.email,
431 password: passwordHash,
432 name: this.name || null,
433 }),
434 });
435
436 if (!response.ok) {
437 const data = await response.json();
438 this.error = data.error || "Registration failed";
439 return;
440 }
441
442 const data = await response.json();
443
444 if (data.email_verification_required) {
445 this.needsEmailVerification = true;
446 this.password = "";
447 this.error = "";
448 this.startResendTimer(data.verification_code_sent_at);
449 return;
450 }
451
452 this.user = data;
453 this.closeModal();
454 await this.checkAuth();
455 window.dispatchEvent(new CustomEvent("auth-changed"));
456 window.location.href = "/classes";
457 } else {
458 const response = await fetch("/api/auth/login", {
459 method: "POST",
460 headers: {
461 "Content-Type": "application/json",
462 },
463 body: JSON.stringify({
464 email: this.email,
465 password: passwordHash,
466 }),
467 });
468
469 if (!response.ok) {
470 const data = await response.json();
471 if (response.status === 401) {
472 this.needsRegistration = true;
473 this.error = "";
474 return;
475 }
476 this.error = data.error || "Login failed";
477 return;
478 }
479
480 const data = await response.json();
481
482 if (data.email_verification_required) {
483 this.needsEmailVerification = true;
484 this.password = "";
485 this.error = "";
486 this.startResendTimer(data.verification_code_sent_at);
487 return;
488 }
489
490 this.user = data;
491 this.closeModal();
492 await this.checkAuth();
493 window.dispatchEvent(new CustomEvent("auth-changed"));
494 window.location.href = "/classes";
495 }
496 } catch (error) {
497 // Catch crypto.subtle errors and other exceptions
498 this.error = error instanceof Error ? error.message : "An error occurred";
499 } finally {
500 this.isSubmitting = false;
501 }
502 }
503
504 private async handleLogout() {
505 this.showModal = false;
506 try {
507 await fetch("/api/auth/logout", { method: "POST" });
508 this.user = null;
509 window.dispatchEvent(new CustomEvent("auth-changed"));
510 window.location.href = "/";
511 } catch {
512 // Silent fail
513 }
514 }
515
516 private toggleMenu() {
517 this.showModal = !this.showModal;
518 }
519
520 private handleEmailInput(e: Event) {
521 this.email = (e.target as HTMLInputElement).value;
522 }
523
524 private handleNameInput(e: Event) {
525 this.name = (e.target as HTMLInputElement).value;
526 }
527
528 private handlePasswordInput(e: Event) {
529 this.password = (e.target as HTMLInputElement).value;
530 }
531
532 private handleVerificationCodeInput(e: Event) {
533 this.verificationCode = (e.target as HTMLInputElement).value;
534 }
535
536 private async handleVerifyEmail(e: Event) {
537 e.preventDefault();
538 this.error = "";
539 this.isSubmitting = true;
540
541 try {
542 const response = await fetch("/api/auth/verify-email", {
543 method: "POST",
544 headers: {
545 "Content-Type": "application/json",
546 },
547 body: JSON.stringify({
548 email: this.email,
549 code: this.verificationCode,
550 }),
551 });
552
553 if (!response.ok) {
554 const data = await response.json();
555 this.error = data.error || "Verification failed";
556 return;
557 }
558
559 // Successfully verified - redirect to classes
560 this.closeModal();
561 await this.checkAuth();
562 window.dispatchEvent(new CustomEvent("auth-changed"));
563 window.location.href = "/classes";
564 } catch (error) {
565 this.error = error instanceof Error ? error.message : "An error occurred";
566 } finally {
567 this.isSubmitting = false;
568 }
569 }
570
571 private handlePasswordBlur() {
572 if (!this.needsRegistration) return;
573
574 const strengthComponent = this.shadowRoot?.querySelector(
575 "password-strength",
576 ) as PasswordStrength | null;
577 if (strengthComponent && this.password) {
578 strengthComponent.checkHIBP(this.password);
579 }
580 }
581
582 private handleStrengthChange(e: CustomEvent<PasswordStrengthResult>) {
583 this.passwordStrength = e.detail;
584 }
585
586 private async handlePasskeyLogin() {
587 this.error = "";
588 this.isSubmitting = true;
589
590 try {
591 const result = await authenticateWithPasskey(this.email || undefined);
592
593 if (!result.success) {
594 this.error = result.error || "Passkey authentication failed";
595 return;
596 }
597
598 // Success - reload to get user info
599 await this.checkAuth();
600 this.closeModal();
601 window.dispatchEvent(new CustomEvent("auth-changed"));
602 window.location.href = "/classes";
603 } finally {
604 this.isSubmitting = false;
605 }
606 }
607
608 private startResendTimer(sentAtTimestamp: number) {
609 // Use provided timestamp
610 this.codeSentAt = sentAtTimestamp;
611
612 // Clear existing interval if any
613 if (this.resendInterval !== null) {
614 clearInterval(this.resendInterval);
615 }
616
617 // Update timer based on elapsed time
618 const updateTimer = () => {
619 if (this.codeSentAt === null) return;
620
621 const now = Math.floor(Date.now() / 1000);
622 const elapsed = now - this.codeSentAt;
623 const remaining = Math.max(0, (5 * 60) - elapsed);
624 this.resendCodeTimer = remaining;
625
626 if (remaining <= 0) {
627 if (this.resendInterval !== null) {
628 clearInterval(this.resendInterval);
629 this.resendInterval = null;
630 }
631 }
632 };
633
634 // Update immediately
635 updateTimer();
636
637 // Then update every second
638 this.resendInterval = window.setInterval(updateTimer, 1000);
639 }
640
641 private async handleResendCode() {
642 this.error = "";
643 this.resendingCode = true;
644
645 try {
646 const response = await fetch("/api/auth/resend-verification-code", {
647 method: "POST",
648 headers: {
649 "Content-Type": "application/json",
650 },
651 body: JSON.stringify({
652 email: this.email,
653 }),
654 });
655
656 if (!response.ok) {
657 const data = await response.json();
658 this.error = data.error || "Failed to resend code";
659 return;
660 }
661
662 // Start the 5-minute timer
663 this.startResendTimer(data.verification_code_sent_at);
664 } catch (error) {
665 this.error = error instanceof Error ? error.message : "An error occurred";
666 } finally {
667 this.resendingCode = false;
668 }
669 }
670
671 private formatTimer(seconds: number): string {
672 const mins = Math.floor(seconds / 60);
673 const secs = seconds % 60;
674 return `${mins}:${secs.toString().padStart(2, '0')}`;
675 }
676
677 override disconnectedCallback() {
678 super.disconnectedCallback();
679 // Clean up timer when component is removed
680 if (this.resendInterval !== null) {
681 clearInterval(this.resendInterval);
682 this.resendInterval = null;
683 }
684 }
685
686 override render() {
687 if (this.loading) {
688 return html`<div class="loading">Loading...</div>`;
689 }
690
691 return html`
692 <div class="auth-container">
693 ${
694 this.user
695 ? html`
696 <button class="auth-button" @click=${this.toggleMenu}>
697 <div class="user-info">
698 <img
699 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
700 alt="Avatar"
701 width="32"
702 height="32"
703 style="border-radius: 50%"
704 />
705 <span class="email">${this.user.name ?? this.user.email}</span>
706 </div>
707 </button>
708 ${
709 this.showModal
710 ? html`
711 <div class="user-menu">
712 <a href="/classes" @click=${this.closeModal}>Classes</a>
713 <a href="/settings" @click=${this.closeModal}>Settings</a>
714 ${
715 this.user.role === "admin"
716 ? html`<a href="/admin" @click=${this.closeModal} class="admin-link">Admin</a>`
717 : ""
718 }
719 <button @click=${this.handleLogout}>Logout</button>
720 </div>
721 `
722 : ""
723 }
724 `
725 : html`
726 <button class="auth-button" @click=${this.openModal}>
727 Sign In
728 </button>
729 `
730 }
731 </div>
732
733 ${
734 this.showModal && !this.user
735 ? html`
736 <div class="modal-overlay" @click=${this.closeModal}>
737 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
738 <h2 class="modal-title">
739 ${this.needsEmailVerification ? "Verify Email" : this.needsRegistration ? "Create Account" : "Sign In"}
740 </h2>
741
742 ${
743 this.needsEmailVerification
744 ? html`
745 <p class="info-text">
746 We sent a 6-digit verification code to <strong>${this.email}</strong>.<br>
747 Check your email and enter the code below.
748 </p>
749
750 <form @submit=${this.handleVerifyEmail}>
751 <div class="form-group">
752 <label for="verification-code">Verification Code</label>
753 <input
754 type="text"
755 id="verification-code"
756 class="verification-code-input"
757 placeholder="000000"
758 .value=${this.verificationCode}
759 @input=${this.handleVerificationCodeInput}
760 required
761 maxlength="6"
762 pattern="[0-9]{6}"
763 inputmode="numeric"
764 ?disabled=${this.isSubmitting}
765 autocomplete="one-time-code"
766 />
767 </div>
768
769 ${
770 this.error
771 ? html`<div class="error-message">${this.error}</div>`
772 : ""
773 }
774
775 <div class="resend-link">
776 ${
777 this.resendCodeTimer > 0
778 ? html`Resend code in ${this.formatTimer(this.resendCodeTimer)}`
779 : html`
780 <button
781 type="button"
782 class="resend-button"
783 @click=${this.handleResendCode}
784 ?disabled=${this.resendingCode}
785 >
786 ${this.resendingCode ? "Sending..." : "Resend code"}
787 </button>
788 `
789 }
790 </div>
791
792 <div class="modal-actions">
793 <button
794 type="submit"
795 class="btn-primary"
796 ?disabled=${this.isSubmitting || this.verificationCode.length !== 6}
797 >
798 ${this.isSubmitting ? "Verifying..." : "Verify Email"}
799 </button>
800 <button
801 type="button"
802 class="btn-secondary"
803 @click=${() => {
804 this.needsEmailVerification = false;
805 this.verificationCode = "";
806 this.error = "";
807 }}
808 ?disabled=${this.isSubmitting}
809 >
810 Back
811 </button>
812 </div>
813 </form>
814 `
815 : html`
816 ${
817 this.needsRegistration
818 ? html`
819 <p class="info-text">
820 Looks like you might not have an account yet. Create one below!
821 </p>
822 `
823 : ""
824 }
825
826 ${
827 !this.needsRegistration && this.passkeySupported
828 ? html`
829 <button
830 type="button"
831 class="btn-passkey"
832 @click=${this.handlePasskeyLogin}
833 ?disabled=${this.isSubmitting}
834 >
835 馃攽 ${this.isSubmitting ? "Loading..." : "Sign in with Passkey"}
836 </button>
837 <div class="divider">or sign in with password</div>
838 `
839 : ""
840 }
841
842 <form @submit=${this.handleSubmit}>
843 <div class="form-group">
844 <input
845 type="email"
846 id="email"
847 placeholder="heidi@awesome.net"
848 .value=${this.email}
849 @input=${this.handleEmailInput}
850 required
851 ?disabled=${this.isSubmitting}
852 />
853 </div>
854
855 ${
856 this.needsRegistration
857 ? html`
858 <div class="form-group">
859 <label for="name">Name (optional)</label>
860 <input
861 type="text"
862 id="name"
863 placeholder="Heidi VanCoolbeans"
864 .value=${this.name}
865 @input=${this.handleNameInput}
866 ?disabled=${this.isSubmitting}
867 />
868 </div>
869 `
870 : ""
871 }
872
873 <div class="form-group">
874 <label for="password">Password</label>
875 <input
876 type="password"
877 id="password"
878 placeholder="*************"
879 .value=${this.password}
880 @input=${this.handlePasswordInput}
881 @blur=${this.handlePasswordBlur}
882 required
883 ?disabled=${this.isSubmitting}
884 />
885 ${
886 this.needsRegistration
887 ? html`<password-strength
888 .password=${this.password}
889 @strength-change=${this.handleStrengthChange}
890 ></password-strength>`
891 : ""
892 }
893 </div>
894
895 ${
896 this.error
897 ? html`<div class="error-message">${this.error}</div>`
898 : ""
899 }
900
901 <div class="modal-actions">
902 <button
903 type="submit"
904 class="btn-primary"
905 ?disabled=${
906 this.isSubmitting ||
907 (this.passwordStrength?.isChecking ?? false) ||
908 (this.needsRegistration &&
909 !(this.passwordStrength?.isValid ?? false))
910 }
911 >
912 ${
913 this.isSubmitting
914 ? "Loading..."
915 : this.needsRegistration
916 ? "Create Account"
917 : "Sign In"
918 }
919 </button>
920 <button
921 type="button"
922 class="btn-neutral"
923 @click=${this.closeModal}
924 ?disabled=${this.isSubmitting}
925 >
926 Cancel
927 </button>
928 </div>
929 </form>
930 `
931 }
932 </div>
933 </div>
934 `
935 : ""
936 }
937 `;
938 }
939}