馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3
4interface User {
5 id: number;
6 email: string;
7 name: string | null;
8 avatar: string;
9 role: "user" | "admin";
10 transcription_count: number;
11 last_login: number | null;
12 created_at: number;
13 subscription_status: string | null;
14 subscription_id: string | null;
15}
16
17@customElement("admin-users")
18export class AdminUsers extends LitElement {
19 @state() users: User[] = [];
20 @state() searchQuery = "";
21 @state() isLoading = true;
22 @state() error: string | null = null;
23 @state() currentUserEmail: string | null = null;
24 @state() revokingSubscriptions = new Set<number>();
25 @state() syncingSubscriptions = new Set<number>();
26
27 static override styles = css`
28 :host {
29 display: block;
30 }
31
32 .error-banner {
33 background: #fecaca;
34 border: 2px solid rgba(220, 38, 38, 0.8);
35 border-radius: 6px;
36 padding: 1rem;
37 margin-bottom: 1.5rem;
38 color: #dc2626;
39 font-weight: 500;
40 }
41
42 .search-box {
43 width: 100%;
44 max-width: 30rem;
45 margin-bottom: 1.5rem;
46 padding: 0.75rem 1rem;
47 border: 2px solid var(--secondary);
48 border-radius: 4px;
49 font-size: 1rem;
50 background: var(--background);
51 color: var(--text);
52 }
53
54 .search-box:focus {
55 outline: none;
56 border-color: var(--primary);
57 }
58
59 .loading,
60 .empty-state {
61 text-align: center;
62 padding: 3rem;
63 color: var(--paynes-gray);
64 }
65
66 .error {
67 background: color-mix(in srgb, red 10%, transparent);
68 border: 1px solid red;
69 color: red;
70 padding: 1rem;
71 border-radius: 4px;
72 margin-bottom: 1rem;
73 }
74
75 .users-grid {
76 display: grid;
77 gap: 1rem;
78 }
79
80 .user-card {
81 background: var(--background);
82 border: 2px solid var(--secondary);
83 border-radius: 8px;
84 padding: 1.5rem;
85 cursor: pointer;
86 transition: border-color 0.2s;
87 }
88
89 .user-card:hover {
90 border-color: var(--primary);
91 }
92
93 .user-card.system {
94 cursor: default;
95 opacity: 0.8;
96 }
97
98 .user-card.system:hover {
99 border-color: var(--secondary);
100 }
101
102 .card-header {
103 display: flex;
104 justify-content: space-between;
105 align-items: flex-start;
106 margin-bottom: 1rem;
107 }
108
109 .user-info {
110 display: flex;
111 align-items: center;
112 gap: 1rem;
113 }
114
115 .user-avatar {
116 width: 3rem;
117 height: 3rem;
118 border-radius: 50%;
119 }
120
121 .user-details {
122 flex: 1;
123 }
124
125 .user-name {
126 font-size: 1.125rem;
127 font-weight: 600;
128 color: var(--text);
129 margin-bottom: 0.25rem;
130 }
131
132 .user-email {
133 font-size: 0.875rem;
134 color: var(--paynes-gray);
135 }
136
137 .admin-badge {
138 background: var(--accent);
139 color: var(--white);
140 padding: 0.5rem 1rem;
141 border-radius: 4px;
142 font-size: 0.75rem;
143 font-weight: 600;
144 text-transform: uppercase;
145 }
146
147 .system-badge {
148 background: var(--paynes-gray);
149 color: var(--white);
150 padding: 0.5rem 1rem;
151 border-radius: 4px;
152 font-size: 0.75rem;
153 font-weight: 600;
154 text-transform: uppercase;
155 }
156
157 .meta-row {
158 display: flex;
159 gap: 2rem;
160 flex-wrap: wrap;
161 margin-bottom: 1rem;
162 }
163
164 .meta-item {
165 display: flex;
166 flex-direction: column;
167 gap: 0.25rem;
168 }
169
170 .meta-label {
171 font-size: 0.75rem;
172 font-weight: 600;
173 text-transform: uppercase;
174 color: var(--paynes-gray);
175 letter-spacing: 0.05em;
176 }
177
178 .meta-value {
179 font-size: 0.875rem;
180 color: var(--text);
181 }
182
183 .timestamp {
184 color: var(--paynes-gray);
185 font-size: 0.875rem;
186 }
187
188 .actions {
189 display: flex;
190 gap: 0.75rem;
191 align-items: center;
192 flex-wrap: wrap;
193 }
194
195 .role-select {
196 padding: 0.5rem 0.75rem;
197 border: 2px solid var(--secondary);
198 border-radius: 4px;
199 font-size: 0.875rem;
200 background: var(--background);
201 color: var(--text);
202 cursor: pointer;
203 font-weight: 600;
204 }
205
206 .role-select:focus {
207 outline: none;
208 border-color: var(--primary);
209 }
210
211 .delete-btn {
212 background: transparent;
213 border: 2px solid #dc2626;
214 color: #dc2626;
215 padding: 0.5rem 1rem;
216 border-radius: 4px;
217 cursor: pointer;
218 font-size: 0.875rem;
219 font-weight: 600;
220 transition: all 0.2s;
221 }
222
223 .delete-btn:hover:not(:disabled) {
224 background: #dc2626;
225 color: var(--white);
226 }
227
228 .delete-btn:disabled {
229 opacity: 0.5;
230 cursor: not-allowed;
231 }
232
233 .revoke-btn {
234 background: transparent;
235 border: 2px solid var(--accent);
236 color: var(--accent);
237 padding: 0.5rem 1rem;
238 border-radius: 4px;
239 cursor: pointer;
240 font-size: 0.875rem;
241 font-weight: 600;
242 transition: all 0.2s;
243 }
244
245 .revoke-btn:hover:not(:disabled) {
246 background: var(--accent);
247 color: var(--white);
248 }
249
250 .revoke-btn:disabled {
251 opacity: 0.5;
252 cursor: not-allowed;
253 }
254
255 .sync-btn {
256 background: transparent;
257 border: 2px solid var(--primary);
258 color: var(--primary);
259 padding: 0.5rem 1rem;
260 border-radius: 4px;
261 cursor: pointer;
262 font-size: 0.875rem;
263 font-weight: 600;
264 transition: all 0.2s;
265 }
266
267 .sync-btn:hover:not(:disabled) {
268 background: var(--primary);
269 color: var(--white);
270 }
271
272 .sync-btn:disabled {
273 opacity: 0.5;
274 cursor: not-allowed;
275 }
276
277 .subscription-badge {
278 background: var(--primary);
279 color: var(--white);
280 padding: 0.25rem 0.5rem;
281 border-radius: 4px;
282 font-size: 0.75rem;
283 font-weight: 600;
284 text-transform: uppercase;
285 }
286
287 .subscription-badge.active {
288 background: var(--primary);
289 color: var(--white);
290 }
291
292 .subscription-badge.none {
293 background: var(--secondary);
294 color: var(--paynes-gray);
295 }
296 `;
297
298 override async connectedCallback() {
299 super.connectedCallback();
300 await this.getCurrentUser();
301 await this.loadUsers();
302 }
303
304 private async getCurrentUser() {
305 try {
306 const response = await fetch("/api/auth/me");
307 if (response.ok) {
308 const user = await response.json();
309 this.currentUserEmail = user.email;
310 }
311 } catch {
312 // Silent fail
313 }
314 }
315
316 private async loadUsers() {
317 this.isLoading = true;
318 this.error = null;
319
320 try {
321 const response = await fetch("/api/admin/users");
322 if (!response.ok) {
323 const data = await response.json();
324 throw new Error(data.error || "Failed to load users");
325 }
326
327 this.users = await response.json();
328 } catch (err) {
329 this.error =
330 err instanceof Error
331 ? err.message
332 : "Failed to load users. Please try again.";
333 } finally {
334 this.isLoading = false;
335 }
336 }
337
338 private async handleRoleChange(
339 userId: number,
340 email: string,
341 newRole: "user" | "admin",
342 oldRole: "user" | "admin",
343 event: Event,
344 ) {
345 const select = event.target as HTMLSelectElement;
346
347 const isDemotingSelf =
348 email === this.currentUserEmail &&
349 oldRole === "admin" &&
350 newRole === "user";
351
352 if (isDemotingSelf) {
353 if (
354 !confirm(
355 "鈿狅笍 WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?",
356 )
357 ) {
358 select.value = oldRole;
359 return;
360 }
361
362 if (
363 !confirm(
364 "鈿狅笍 FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?",
365 )
366 ) {
367 select.value = oldRole;
368 return;
369 }
370 } else {
371 if (!confirm(`Change user role to ${newRole}?`)) {
372 select.value = oldRole;
373 return;
374 }
375 }
376
377 try {
378 const response = await fetch(`/api/admin/users/${userId}/role`, {
379 method: "PUT",
380 headers: { "Content-Type": "application/json" },
381 body: JSON.stringify({ role: newRole }),
382 });
383
384 if (!response.ok) {
385 const data = await response.json();
386 throw new Error(data.error || "Failed to update role");
387 }
388
389 if (isDemotingSelf) {
390 window.location.href = "/";
391 } else {
392 await this.loadUsers();
393 }
394 } catch (err) {
395 this.error =
396 err instanceof Error ? err.message : "Failed to update user role";
397 select.value = oldRole;
398 }
399 }
400
401 @state() deleteState: {
402 id: number;
403 type: "user" | "revoke";
404 clicks: number;
405 timeout: number | null;
406 } | null = null;
407
408 private handleDeleteClick(userId: number, event: Event) {
409 event.stopPropagation();
410
411 // If this is a different item or timeout expired, reset
412 if (
413 !this.deleteState ||
414 this.deleteState.id !== userId ||
415 this.deleteState.type !== "user"
416 ) {
417 // Clear any existing timeout
418 if (this.deleteState?.timeout) {
419 clearTimeout(this.deleteState.timeout);
420 }
421
422 // Set first click
423 const timeout = window.setTimeout(() => {
424 this.deleteState = null;
425 }, 1000);
426
427 this.deleteState = { id: userId, type: "user", clicks: 1, timeout };
428 return;
429 }
430
431 // Increment clicks
432 const newClicks = this.deleteState.clicks + 1;
433
434 // Clear existing timeout
435 if (this.deleteState.timeout) {
436 clearTimeout(this.deleteState.timeout);
437 }
438
439 // Third click - actually delete
440 if (newClicks === 3) {
441 this.deleteState = null;
442 this.performDeleteUser(userId);
443 return;
444 }
445
446 // Second click - reset timeout
447 const timeout = window.setTimeout(() => {
448 this.deleteState = null;
449 }, 1000);
450
451 this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout };
452 }
453
454 private async performDeleteUser(userId: number) {
455 this.error = null;
456 try {
457 const response = await fetch(`/api/admin/users/${userId}`, {
458 method: "DELETE",
459 });
460
461 if (!response.ok) {
462 const data = await response.json();
463 throw new Error(data.error || "Failed to delete user");
464 }
465
466 // Remove user from local array instead of reloading
467 this.users = this.users.filter((u) => u.id !== userId);
468 this.dispatchEvent(new CustomEvent("user-deleted"));
469 } catch (err) {
470 this.error =
471 err instanceof Error
472 ? err.message
473 : "Failed to delete user. Please try again.";
474 }
475 }
476
477 private handleRevokeClick(
478 userId: number,
479 email: string,
480 subscriptionId: string,
481 event: Event,
482 ) {
483 event.stopPropagation();
484
485 // If this is a different item or timeout expired, reset
486 if (
487 !this.deleteState ||
488 this.deleteState.id !== userId ||
489 this.deleteState.type !== "revoke"
490 ) {
491 // Clear any existing timeout
492 if (this.deleteState?.timeout) {
493 clearTimeout(this.deleteState.timeout);
494 }
495
496 // Set first click
497 const timeout = window.setTimeout(() => {
498 this.deleteState = null;
499 }, 1000);
500
501 this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout };
502 return;
503 }
504
505 // Increment clicks
506 const newClicks = this.deleteState.clicks + 1;
507
508 // Clear existing timeout
509 if (this.deleteState.timeout) {
510 clearTimeout(this.deleteState.timeout);
511 }
512
513 // Third click - actually revoke
514 if (newClicks === 3) {
515 this.deleteState = null;
516 this.performRevokeSubscription(userId, email, subscriptionId);
517 return;
518 }
519
520 // Second click - reset timeout
521 const timeout = window.setTimeout(() => {
522 this.deleteState = null;
523 }, 1000);
524
525 this.deleteState = {
526 id: userId,
527 type: "revoke",
528 clicks: newClicks,
529 timeout,
530 };
531 }
532
533 private async performRevokeSubscription(
534 userId: number,
535 _email: string,
536 subscriptionId: string,
537 ) {
538 this.revokingSubscriptions.add(userId);
539 this.requestUpdate();
540 this.error = null;
541
542 try {
543 const response = await fetch(`/api/admin/users/${userId}/subscription`, {
544 method: "DELETE",
545 headers: { "Content-Type": "application/json" },
546 body: JSON.stringify({ subscriptionId }),
547 });
548
549 if (!response.ok) {
550 const data = await response.json();
551 throw new Error(data.error || "Failed to revoke subscription");
552 }
553
554 await this.loadUsers();
555 } catch (err) {
556 this.error =
557 err instanceof Error ? err.message : "Failed to revoke subscription";
558 this.revokingSubscriptions.delete(userId);
559 }
560 }
561
562 private async handleSyncSubscription(userId: number, event: Event) {
563 event.stopPropagation();
564
565 this.syncingSubscriptions.add(userId);
566 this.requestUpdate();
567 this.error = null;
568
569 try {
570 const response = await fetch(`/api/admin/users/${userId}/subscription`, {
571 method: "PUT",
572 headers: { "Content-Type": "application/json" },
573 });
574
575 if (!response.ok) {
576 const data = await response.json();
577 // Don't show error if there's just no subscription
578 if (response.status !== 404) {
579 this.error = data.error || "Failed to sync subscription";
580 }
581 return;
582 }
583
584 await this.loadUsers();
585 } finally {
586 this.syncingSubscriptions.delete(userId);
587 this.requestUpdate();
588 }
589 }
590
591 private getDeleteButtonText(userId: number, type: "user" | "revoke"): string {
592 if (
593 !this.deleteState ||
594 this.deleteState.id !== userId ||
595 this.deleteState.type !== type
596 ) {
597 return type === "user" ? "Delete User" : "Revoke Subscription";
598 }
599
600 if (this.deleteState.clicks === 1) {
601 return "Are you sure?";
602 }
603
604 if (this.deleteState.clicks === 2) {
605 return "Final warning!";
606 }
607
608 return type === "user" ? "Delete User" : "Revoke Subscription";
609 }
610
611 private handleCardClick(userId: number, event: Event) {
612 // Don't open modal for ghost user
613 if (userId === 0) {
614 return;
615 }
616
617 // Don't open modal if clicking on delete button, revoke button, sync button, or role select
618 if (
619 (event.target as HTMLElement).closest(".delete-btn") ||
620 (event.target as HTMLElement).closest(".revoke-btn") ||
621 (event.target as HTMLElement).closest(".sync-btn") ||
622 (event.target as HTMLElement).closest(".role-select")
623 ) {
624 return;
625 }
626 this.dispatchEvent(
627 new CustomEvent("open-user", {
628 detail: { id: userId },
629 }),
630 );
631 }
632
633 private formatTimestamp(timestamp: number | null): string {
634 if (!timestamp) return "Never";
635 const date = new Date(timestamp * 1000);
636 return date.toLocaleString();
637 }
638
639 private get filteredUsers() {
640 const query = this.searchQuery.toLowerCase();
641
642 // Filter users based on search query
643 let filtered = this.users.filter(
644 (u) =>
645 u.email.toLowerCase().includes(query) ||
646 u.name?.toLowerCase().includes(query),
647 );
648
649 // Hide ghost user unless specifically searched for
650 if (
651 !query.includes("deleted") &&
652 !query.includes("ghost") &&
653 !query.includes("system")
654 ) {
655 filtered = filtered.filter((u) => u.id !== 0);
656 }
657
658 return filtered;
659 }
660
661 override render() {
662 if (this.isLoading) {
663 return html`<div class="loading">Loading users...</div>`;
664 }
665
666 if (this.error) {
667 return html`
668 <div class="error-banner">${this.error}</div>
669 <button @click=${this.loadUsers}>Retry</button>
670 `;
671 }
672
673 const filtered = this.filteredUsers;
674
675 return html`
676 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""}
677
678 <input
679 type="text"
680 class="search-box"
681 placeholder="Search by name or email..."
682 .value=${this.searchQuery}
683 @input=${(e: Event) => {
684 this.searchQuery = (e.target as HTMLInputElement).value;
685 }}
686 />
687
688 ${
689 filtered.length === 0
690 ? html`<div class="empty-state">No users found</div>`
691 : html`
692 <div class="users-grid">
693 ${filtered.map(
694 (u) => html`
695 <div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
696 <div class="card-header">
697 <div class="user-info">
698 <img
699 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
700 alt="Avatar"
701 class="user-avatar"
702 />
703 <div class="user-details">
704 <div class="user-name">${u.name || "Anonymous"}</div>
705 <div class="user-email">${u.email}</div>
706 </div>
707 </div>
708 ${
709 u.id === 0
710 ? html`<span class="system-badge">System</span>`
711 : u.role === "admin"
712 ? html`<span class="admin-badge">Admin</span>`
713 : ""
714 }
715 </div>
716
717 <div class="meta-row">
718 <div class="meta-item">
719 <div class="meta-label">Transcriptions</div>
720 <div class="meta-value">${u.transcription_count}</div>
721 </div>
722 <div class="meta-item">
723 <div class="meta-label">Subscription</div>
724 <div class="meta-value">
725 ${
726 u.subscription_status
727 ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
728 : html`<span class="subscription-badge none">None</span>`
729 }
730 </div>
731 </div>
732 <div class="meta-item">
733 <div class="meta-label">Last Login</div>
734 <div class="meta-value timestamp">
735 ${this.formatTimestamp(u.last_login)}
736 </div>
737 </div>
738 <div class="meta-item">
739 <div class="meta-label">Joined</div>
740 <div class="meta-value timestamp">
741 ${this.formatTimestamp(u.created_at)}
742 </div>
743 </div>
744 </div>
745
746 <div class="actions">
747 ${
748 u.id === 0
749 ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
750 : html`
751 <select
752 class="role-select"
753 .value=${u.role}
754 @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)}
755 >
756 <option value="user">User</option>
757 <option value="admin">Admin</option>
758 </select>
759 <button
760 class="sync-btn"
761 ?disabled=${this.syncingSubscriptions.has(u.id)}
762 @click=${(e: Event) => this.handleSyncSubscription(u.id, e)}
763 title="Sync subscription status from Polar"
764 >
765 ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "馃攧 Sync"}
766 </button>
767 <button
768 class="revoke-btn"
769 ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
770 @click=${(e: Event) => {
771 if (u.subscription_id) {
772 this.handleRevokeClick(
773 u.id,
774 u.email,
775 u.subscription_id,
776 e,
777 );
778 }
779 }}
780 >
781 ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")}
782 </button>
783 <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, e)}>
784 ${this.getDeleteButtonText(u.id, "user")}
785 </button>
786 `
787 }
788 </div>
789 </div>
790 `,
791 )}
792 </div>
793 `
794 }
795 `;
796 }
797}