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