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