馃 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 } else {
391 const response = await fetch("/api/auth/login", {
392 method: "POST",
393 headers: {
394 "Content-Type": "application/json",
395 },
396 body: JSON.stringify({
397 email: this.email,
398 password: passwordHash,
399 }),
400 });
401
402 if (!response.ok) {
403 const data = await response.json();
404 if (response.status === 401) {
405 this.needsRegistration = true;
406 this.error = "";
407 return;
408 }
409 this.error = data.error || "Login failed";
410 return;
411 }
412
413 this.user = await response.json();
414 this.closeModal();
415 await this.checkAuth();
416 window.dispatchEvent(new CustomEvent("auth-changed"));
417 }
418 } catch (error) {
419 // Catch crypto.subtle errors and other exceptions
420 this.error = error instanceof Error ? error.message : "An error occurred";
421 } finally {
422 this.isSubmitting = false;
423 }
424 }
425
426 private async handleLogout() {
427 try {
428 await fetch("/api/auth/logout", { method: "POST" });
429 this.user = null;
430 window.dispatchEvent(new CustomEvent("auth-changed"));
431 window.location.href = "/";
432 } catch {
433 // Silent fail
434 }
435 }
436
437 private toggleMenu() {
438 this.showModal = !this.showModal;
439 }
440
441 private handleEmailInput(e: Event) {
442 this.email = (e.target as HTMLInputElement).value;
443 }
444
445 private handleNameInput(e: Event) {
446 this.name = (e.target as HTMLInputElement).value;
447 }
448
449 private handlePasswordInput(e: Event) {
450 this.password = (e.target as HTMLInputElement).value;
451 }
452
453 private handlePasswordBlur() {
454 if (!this.needsRegistration) return;
455
456 const strengthComponent = this.shadowRoot?.querySelector(
457 "password-strength",
458 ) as PasswordStrength | null;
459 if (strengthComponent && this.password) {
460 strengthComponent.checkHIBP(this.password);
461 }
462 }
463
464 private handleStrengthChange(e: CustomEvent<PasswordStrengthResult>) {
465 this.passwordStrength = e.detail;
466 }
467
468 private async handlePasskeyLogin() {
469 this.error = "";
470 this.isSubmitting = true;
471
472 try {
473 const result = await authenticateWithPasskey(this.email || undefined);
474
475 if (!result.success) {
476 this.error = result.error || "Passkey authentication failed";
477 return;
478 }
479
480 // Success - reload to get user info
481 await this.checkAuth();
482 this.closeModal();
483 window.dispatchEvent(new CustomEvent("auth-changed"));
484 } finally {
485 this.isSubmitting = false;
486 }
487 }
488
489 override render() {
490 if (this.loading) {
491 return html`<div class="loading">Loading...</div>`;
492 }
493
494 return html`
495 <div class="auth-container">
496 ${
497 this.user
498 ? html`
499 <button class="auth-button" @click=${this.toggleMenu}>
500 <div class="user-info">
501 <img
502 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
503 alt="Avatar"
504 width="32"
505 height="32"
506 style="border-radius: 50%"
507 />
508 <span class="email">${this.user.name ?? this.user.email}</span>
509 </div>
510 </button>
511 ${
512 this.showModal
513 ? html`
514 <div class="user-menu">
515 <a href="/classes" @click=${this.closeModal}>Classes</a>
516 <a href="/settings" @click=${this.closeModal}>Settings</a>
517 ${
518 this.user.role === "admin"
519 ? html`<a href="/admin" @click=${this.closeModal} class="admin-link">Admin</a>`
520 : ""
521 }
522 <button @click=${this.handleLogout}>Logout</button>
523 </div>
524 `
525 : ""
526 }
527 `
528 : html`
529 <button class="auth-button" @click=${this.openModal}>
530 Sign In
531 </button>
532 `
533 }
534 </div>
535
536 ${
537 this.showModal && !this.user
538 ? html`
539 <div class="modal-overlay" @click=${this.closeModal}>
540 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
541 <h2 class="modal-title">
542 ${this.needsRegistration ? "Create Account" : "Sign In"}
543 </h2>
544
545 ${
546 this.needsRegistration
547 ? html`
548 <p class="info-text">
549 Looks like you might not have an account yet. Create one below!
550 </p>
551 `
552 : ""
553 }
554
555 ${
556 !this.needsRegistration && this.passkeySupported
557 ? html`
558 <button
559 type="button"
560 class="btn-passkey"
561 @click=${this.handlePasskeyLogin}
562 ?disabled=${this.isSubmitting}
563 >
564 馃攽 ${this.isSubmitting ? "Loading..." : "Sign in with Passkey"}
565 </button>
566 <div class="divider">or sign in with password</div>
567 `
568 : ""
569 }
570
571 <form @submit=${this.handleSubmit}>
572 <div class="form-group">
573 <input
574 type="email"
575 id="email"
576 placeholder="heidi@awesome.net"
577 .value=${this.email}
578 @input=${this.handleEmailInput}
579 required
580 ?disabled=${this.isSubmitting}
581 />
582 </div>
583
584 ${
585 this.needsRegistration
586 ? html`
587 <div class="form-group">
588 <label for="name">Name (optional)</label>
589 <input
590 type="text"
591 id="name"
592 placeholder="Heidi VanCoolbeans"
593 .value=${this.name}
594 @input=${this.handleNameInput}
595 ?disabled=${this.isSubmitting}
596 />
597 </div>
598 `
599 : ""
600 }
601
602 <div class="form-group">
603 <label for="password">Password</label>
604 <input
605 type="password"
606 id="password"
607 placeholder="*************"
608 .value=${this.password}
609 @input=${this.handlePasswordInput}
610 @blur=${this.handlePasswordBlur}
611 required
612 ?disabled=${this.isSubmitting}
613 />
614 ${
615 this.needsRegistration
616 ? html`<password-strength
617 .password=${this.password}
618 @strength-change=${this.handleStrengthChange}
619 ></password-strength>`
620 : ""
621 }
622 </div>
623
624 ${
625 this.error
626 ? html`<div class="error-message">${this.error}</div>`
627 : ""
628 }
629
630 <div class="modal-actions">
631 <button
632 type="submit"
633 class="btn-primary"
634 ?disabled=${
635 this.isSubmitting ||
636 (this.passwordStrength?.isChecking ?? false) ||
637 (this.needsRegistration &&
638 !(this.passwordStrength?.isValid ?? false))
639 }
640 >
641 ${
642 this.isSubmitting
643 ? "Loading..."
644 : this.needsRegistration
645 ? "Create Account"
646 : "Sign In"
647 }
648 </button>
649 <button
650 type="button"
651 class="btn-neutral"
652 @click=${this.closeModal}
653 ?disabled=${this.isSubmitting}
654 >
655 Cancel
656 </button>
657 </div>
658 </form>
659 </div>
660 </div>
661 `
662 : ""
663 }
664 `;
665 }
666}