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