馃 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 }
259 } finally {
260 this.loading = false;
261 }
262 }
263
264 private openModal() {
265 this.showModal = true;
266 this.needsRegistration = false;
267 this.email = "";
268 this.password = "";
269 this.name = "";
270 this.error = "";
271 }
272
273 private closeModal() {
274 this.showModal = false;
275 this.needsRegistration = false;
276 this.email = "";
277 this.password = "";
278 this.name = "";
279 this.error = "";
280 }
281
282 private async handleSubmit(e: Event) {
283 e.preventDefault();
284 this.error = "";
285 this.isSubmitting = true;
286
287 try {
288 if (this.needsRegistration) {
289 const response = await fetch("/api/auth/register", {
290 method: "POST",
291 headers: {
292 "Content-Type": "application/json",
293 },
294 body: JSON.stringify({
295 email: this.email,
296 password: this.password,
297 name: this.name || null,
298 }),
299 });
300
301 if (!response.ok) {
302 const data = await response.json();
303 this.error = data.error || "Registration failed";
304 return;
305 }
306
307 this.user = await response.json();
308 this.closeModal();
309 await this.checkAuth();
310 window.dispatchEvent(new CustomEvent("auth-changed"));
311 } else {
312 const response = await fetch("/api/auth/login", {
313 method: "POST",
314 headers: {
315 "Content-Type": "application/json",
316 },
317 body: JSON.stringify({
318 email: this.email,
319 password: this.password,
320 }),
321 });
322
323 if (!response.ok) {
324 const data = await response.json();
325
326 if (
327 response.status === 401 &&
328 data.error?.includes("Invalid email")
329 ) {
330 this.needsRegistration = true;
331 this.error = "";
332 return;
333 }
334
335 this.error = data.error || "Login failed";
336 return;
337 }
338
339 this.user = await response.json();
340 this.closeModal();
341 await this.checkAuth();
342 window.dispatchEvent(new CustomEvent("auth-changed"));
343 }
344 } finally {
345 this.isSubmitting = false;
346 }
347 }
348
349 private async handleLogout() {
350 try {
351 await fetch("/api/auth/logout", { method: "POST" });
352 this.user = null;
353 window.dispatchEvent(new CustomEvent("auth-changed"));
354 } catch {
355 // Silent fail
356 }
357 }
358
359 private toggleMenu() {
360 this.showModal = !this.showModal;
361 }
362
363 private handleEmailInput(e: Event) {
364 this.email = (e.target as HTMLInputElement).value;
365 }
366
367 private handleNameInput(e: Event) {
368 this.name = (e.target as HTMLInputElement).value;
369 }
370
371 private handlePasswordInput(e: Event) {
372 this.password = (e.target as HTMLInputElement).value;
373 }
374
375 override render() {
376 if (this.loading) {
377 return html`<div class="loading">Loading...</div>`;
378 }
379
380 return html`
381 <div class="auth-container">
382 ${
383 this.user
384 ? html`
385 <button class="auth-button" @click=${this.toggleMenu}>
386 <div class="user-info">
387 <img
388 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
389 alt="Avatar"
390 width="32"
391 height="32"
392 style="border-radius: 50%"
393 />
394 <span class="email">${this.user.name ?? this.user.email}</span>
395 </div>
396 </button>
397 ${
398 this.showModal
399 ? html`
400 <div class="user-menu">
401 <a href="/transcribe" @click=${this.closeModal}>Transcribe</a>
402 <a href="/settings" @click=${this.closeModal}>Settings</a>
403 <button @click=${this.handleLogout}>Logout</button>
404 </div>
405 `
406 : ""
407 }
408 `
409 : html`
410 <button class="auth-button" @click=${this.openModal}>
411 Sign In
412 </button>
413 `
414 }
415 </div>
416
417 ${
418 this.showModal && !this.user
419 ? html`
420 <div class="modal-overlay" @click=${this.closeModal}>
421 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
422 <h2 class="modal-title">
423 ${this.needsRegistration ? "Create Account" : "Sign In"}
424 </h2>
425
426 ${
427 this.needsRegistration
428 ? html`
429 <p class="info-text">
430 That email isn't registered yet. Let's create an
431 account!
432 </p>
433 `
434 : ""
435 }
436
437 <form @submit=${this.handleSubmit}>
438 <div class="form-group">
439 <label for="email">Email</label>
440 <input
441 type="email"
442 id="email"
443 .value=${this.email}
444 @input=${this.handleEmailInput}
445 required
446 ?disabled=${this.isSubmitting}
447 />
448 </div>
449
450 ${
451 this.needsRegistration
452 ? html`
453 <div class="form-group">
454 <label for="name">Name (optional)</label>
455 <input
456 type="text"
457 id="name"
458 .value=${this.name}
459 @input=${this.handleNameInput}
460 ?disabled=${this.isSubmitting}
461 />
462 </div>
463 `
464 : ""
465 }
466
467 <div class="form-group">
468 <label for="password">Password</label>
469 <input
470 type="password"
471 id="password"
472 .value=${this.password}
473 @input=${this.handlePasswordInput}
474 required
475 ?disabled=${this.isSubmitting}
476 />
477 </div>
478
479 ${
480 this.error
481 ? html`<div class="error-message">${this.error}</div>`
482 : ""
483 }
484
485 <div class="modal-actions">
486 <button
487 type="submit"
488 class="btn-primary"
489 ?disabled=${this.isSubmitting}
490 >
491 ${
492 this.isSubmitting
493 ? "Loading..."
494 : this.needsRegistration
495 ? "Create Account"
496 : "Sign In"
497 }
498 </button>
499 <button
500 type="button"
501 class="btn-neutral"
502 @click=${this.closeModal}
503 ?disabled=${this.isSubmitting}
504 >
505 Cancel
506 </button>
507 </div>
508 </form>
509 </div>
510 </div>
511 `
512 : ""
513 }
514 `;
515 }
516}