馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3
4interface User {
5 email: string;
6 name: string | null;
7 avatar: string;
8}
9
10@customElement("auth-component")
11export class AuthComponent extends LitElement {
12 @state() user: User | null = null;
13 @state() loading = true;
14 @state() showModal = false;
15 @state() email = "";
16 @state() password = "";
17 @state() name = "";
18 @state() error = "";
19 @state() isSubmitting = false;
20 @state() needsRegistration = false;
21
22 static override styles = css`
23 :host {
24 display: block;
25 }
26
27 .auth-container {
28 position: relative;
29 }
30
31 .auth-button {
32 display: flex;
33 align-items: center;
34 gap: 0.5rem;
35 padding: 0.5rem 1rem;
36 background: var(--primary);
37 color: white;
38 border: 2px solid var(--primary);
39 border-radius: 8px;
40 cursor: pointer;
41 font-size: 1rem;
42 font-weight: 500;
43 transition: all 0.2s;
44 font-family: inherit;
45 }
46
47 .auth-button:hover {
48 background: transparent;
49 color: var(--primary);
50 }
51
52 .auth-button:hover .email {
53 color: var(--primary);
54 }
55
56 .auth-button img {
57 transition: all 0.2s;
58 }
59
60 .auth-button:hover img {
61 opacity: 0.8;
62 }
63
64 .user-info {
65 display: flex;
66 align-items: center;
67 gap: 0.75rem;
68 }
69
70 .email {
71 font-weight: 500;
72 color: white;
73 font-size: 0.875rem;
74 transition: all 0.2s;
75 }
76
77 .modal-overlay {
78 position: fixed;
79 top: 0;
80 left: 0;
81 right: 0;
82 bottom: 0;
83 background: rgba(0, 0, 0, 0.5);
84 display: flex;
85 align-items: center;
86 justify-content: center;
87 z-index: 2000;
88 padding: 1rem;
89 }
90
91 .modal {
92 background: var(--background);
93 border: 2px solid var(--secondary);
94 border-radius: 12px;
95 padding: 2rem;
96 max-width: 400px;
97 width: 100%;
98 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
99 }
100
101 .modal-title {
102 margin-top: 0;
103 margin-bottom: 1rem;
104 color: var(--text);
105 }
106
107 .form-group {
108 margin-bottom: 1rem;
109 }
110
111 label {
112 display: block;
113 margin-bottom: 0.25rem;
114 font-weight: 500;
115 color: var(--text);
116 font-size: 0.875rem;
117 }
118
119 input {
120 width: 100%;
121 padding: 0.75rem;
122 border: 2px solid var(--secondary);
123 border-radius: 6px;
124 font-size: 1rem;
125 font-family: inherit;
126 background: var(--background);
127 color: var(--text);
128 transition: all 0.2s;
129 box-sizing: border-box;
130 }
131
132 input:focus {
133 outline: none;
134 border-color: var(--primary);
135 }
136
137 .error-message {
138 color: var(--accent);
139 font-size: 0.875rem;
140 margin-top: 1rem;
141 }
142
143 button {
144 padding: 0.75rem 1.5rem;
145 border: 2px solid var(--primary);
146 border-radius: 6px;
147 font-size: 1rem;
148 font-weight: 500;
149 cursor: pointer;
150 transition: all 0.2s;
151 font-family: inherit;
152 }
153
154 button:disabled {
155 opacity: 0.6;
156 cursor: not-allowed;
157 }
158
159 .btn-primary {
160 background: var(--primary);
161 color: white;
162 flex: 1;
163 }
164
165 .btn-primary:hover:not(:disabled) {
166 background: transparent;
167 color: var(--primary);
168 }
169
170 .btn-neutral {
171 background: transparent;
172 color: var(--text);
173 border-color: var(--secondary);
174 }
175
176 .btn-neutral:hover:not(:disabled) {
177 border-color: var(--primary);
178 color: var(--primary);
179 }
180
181 .btn-rejection {
182 background: transparent;
183 color: var(--accent);
184 border-color: var(--accent);
185 }
186
187 .btn-rejection:hover:not(:disabled) {
188 background: var(--accent);
189 color: white;
190 }
191
192 .modal-actions {
193 display: flex;
194 gap: 0.5rem;
195 margin-top: 1rem;
196 }
197
198 .user-menu {
199 position: absolute;
200 top: calc(100% + 0.5rem);
201 right: 0;
202 background: var(--background);
203 border: 2px solid var(--secondary);
204 border-radius: 8px;
205 padding: 0.5rem;
206 min-width: 200px;
207 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
208 display: flex;
209 flex-direction: column;
210 gap: 0.5rem;
211 z-index: 100;
212 }
213
214 .user-menu a,
215 .user-menu button {
216 padding: 0.75rem 1rem;
217 background: transparent;
218 color: var(--text);
219 text-decoration: none;
220 border: none;
221 border-radius: 6px;
222 font-weight: 500;
223 text-align: left;
224 transition: all 0.2s;
225 font-family: inherit;
226 font-size: 1rem;
227 cursor: pointer;
228 }
229
230 .user-menu a:hover,
231 .user-menu button:hover {
232 background: var(--secondary);
233 }
234
235 .loading {
236 font-size: 0.875rem;
237 color: var(--text);
238 }
239
240 .info-text {
241 color: var(--text);
242 font-size: 0.875rem;
243 margin: 0;
244 }
245 `;
246
247 override async connectedCallback() {
248 super.connectedCallback();
249 await this.checkAuth();
250 }
251
252 async checkAuth() {
253 try {
254 const response = await fetch("/api/auth/me");
255
256 if (response.ok) {
257 this.user = await response.json();
258 } else if (window.location.pathname !== "/") {
259 window.location.href = "/";
260 }
261 } finally {
262 this.loading = false;
263 }
264 }
265
266 private openModal() {
267 this.showModal = true;
268 this.needsRegistration = false;
269 this.email = "";
270 this.password = "";
271 this.name = "";
272 this.error = "";
273 }
274
275 private closeModal() {
276 this.showModal = false;
277 this.needsRegistration = false;
278 this.email = "";
279 this.password = "";
280 this.name = "";
281 this.error = "";
282 }
283
284 private async handleSubmit(e: Event) {
285 e.preventDefault();
286 this.error = "";
287 this.isSubmitting = true;
288
289 try {
290 if (this.needsRegistration) {
291 const response = await fetch("/api/auth/register", {
292 method: "POST",
293 headers: {
294 "Content-Type": "application/json",
295 },
296 body: JSON.stringify({
297 email: this.email,
298 password: this.password,
299 name: this.name || null,
300 }),
301 });
302
303 if (!response.ok) {
304 const data = await response.json();
305 this.error = data.error || "Registration failed";
306 return;
307 }
308
309 this.user = await response.json();
310 this.closeModal();
311 await this.checkAuth();
312 window.dispatchEvent(new CustomEvent("auth-changed"));
313 } else {
314 const response = await fetch("/api/auth/login", {
315 method: "POST",
316 headers: {
317 "Content-Type": "application/json",
318 },
319 body: JSON.stringify({
320 email: this.email,
321 password: this.password,
322 }),
323 });
324
325 if (!response.ok) {
326 const data = await response.json();
327
328 if (
329 response.status === 401 &&
330 data.error?.includes("Invalid email")
331 ) {
332 this.needsRegistration = true;
333 this.error = "";
334 return;
335 }
336
337 this.error = data.error || "Login failed";
338 return;
339 }
340
341 this.user = await response.json();
342 this.closeModal();
343 await this.checkAuth();
344 window.dispatchEvent(new CustomEvent("auth-changed"));
345 }
346 } finally {
347 this.isSubmitting = false;
348 }
349 }
350
351 private async handleLogout() {
352 try {
353 await fetch("/api/auth/logout", { method: "POST" });
354 this.user = null;
355 window.dispatchEvent(new CustomEvent("auth-changed"));
356 window.location.href = "/";
357 } catch {
358 // Silent fail
359 }
360 }
361
362 private toggleMenu() {
363 this.showModal = !this.showModal;
364 }
365
366 private handleEmailInput(e: Event) {
367 this.email = (e.target as HTMLInputElement).value;
368 }
369
370 private handleNameInput(e: Event) {
371 this.name = (e.target as HTMLInputElement).value;
372 }
373
374 private handlePasswordInput(e: Event) {
375 this.password = (e.target as HTMLInputElement).value;
376 }
377
378 override render() {
379 if (this.loading) {
380 return html`<div class="loading">Loading...</div>`;
381 }
382
383 return html`
384 <div class="auth-container">
385 ${
386 this.user
387 ? html`
388 <button class="auth-button" @click=${this.toggleMenu}>
389 <div class="user-info">
390 <img
391 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
392 alt="Avatar"
393 width="32"
394 height="32"
395 style="border-radius: 50%"
396 />
397 <span class="email">${this.user.name ?? this.user.email}</span>
398 </div>
399 </button>
400 ${
401 this.showModal
402 ? html`
403 <div class="user-menu">
404 <a href="/transcribe" @click=${this.closeModal}>Transcribe</a>
405 <a href="/settings" @click=${this.closeModal}>Settings</a>
406 <button @click=${this.handleLogout}>Logout</button>
407 </div>
408 `
409 : ""
410 }
411 `
412 : html`
413 <button class="auth-button" @click=${this.openModal}>
414 Sign In
415 </button>
416 `
417 }
418 </div>
419
420 ${
421 this.showModal && !this.user
422 ? html`
423 <div class="modal-overlay" @click=${this.closeModal}>
424 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
425 <h2 class="modal-title">
426 ${this.needsRegistration ? "Create Account" : "Sign In"}
427 </h2>
428
429 ${
430 this.needsRegistration
431 ? html`
432 <p class="info-text">
433 That email isn't registered yet. Let's create an
434 account!
435 </p>
436 `
437 : ""
438 }
439
440 <form @submit=${this.handleSubmit}>
441 <div class="form-group">
442 <label for="email">Email</label>
443 <input
444 type="email"
445 id="email"
446 .value=${this.email}
447 @input=${this.handleEmailInput}
448 required
449 ?disabled=${this.isSubmitting}
450 />
451 </div>
452
453 ${
454 this.needsRegistration
455 ? html`
456 <div class="form-group">
457 <label for="name">Name (optional)</label>
458 <input
459 type="text"
460 id="name"
461 .value=${this.name}
462 @input=${this.handleNameInput}
463 ?disabled=${this.isSubmitting}
464 />
465 </div>
466 `
467 : ""
468 }
469
470 <div class="form-group">
471 <label for="password">Password</label>
472 <input
473 type="password"
474 id="password"
475 .value=${this.password}
476 @input=${this.handlePasswordInput}
477 required
478 ?disabled=${this.isSubmitting}
479 />
480 </div>
481
482 ${
483 this.error
484 ? html`<div class="error-message">${this.error}</div>`
485 : ""
486 }
487
488 <div class="modal-actions">
489 <button
490 type="submit"
491 class="btn-primary"
492 ?disabled=${this.isSubmitting}
493 >
494 ${
495 this.isSubmitting
496 ? "Loading..."
497 : this.needsRegistration
498 ? "Create Account"
499 : "Sign In"
500 }
501 </button>
502 <button
503 type="button"
504 class="btn-neutral"
505 @click=${this.closeModal}
506 ?disabled=${this.isSubmitting}
507 >
508 Cancel
509 </button>
510 </div>
511 </form>
512 </div>
513 </div>
514 `
515 : ""
516 }
517 `;
518 }
519}