馃 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 const result = await response.json();
328 this.users = result.data || result;
329 } catch (err) {
330 this.error =
331 err instanceof Error
332 ? err.message
333 : "Failed to load users. Please try again.";
334 } finally {
335 this.isLoading = false;
336 }
337 }
338
339 private async handleRoleChange(
340 userId: number,
341 email: string,
342 newRole: "user" | "admin",
343 oldRole: "user" | "admin",
344 event: Event,
345 ) {
346 const select = event.target as HTMLSelectElement;
347
348 const isDemotingSelf =
349 email === this.currentUserEmail &&
350 oldRole === "admin" &&
351 newRole === "user";
352
353 if (isDemotingSelf) {
354 if (
355 !confirm(
356 "鈿狅笍 WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?",
357 )
358 ) {
359 select.value = oldRole;
360 return;
361 }
362
363 if (
364 !confirm(
365 "鈿狅笍 FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?",
366 )
367 ) {
368 select.value = oldRole;
369 return;
370 }
371 } else {
372 if (!confirm(`Change user role to ${newRole}?`)) {
373 select.value = oldRole;
374 return;
375 }
376 }
377
378 try {
379 const response = await fetch(`/api/admin/users/${userId}/role`, {
380 method: "PUT",
381 headers: { "Content-Type": "application/json" },
382 body: JSON.stringify({ role: newRole }),
383 });
384
385 if (!response.ok) {
386 const data = await response.json();
387 throw new Error(data.error || "Failed to update role");
388 }
389
390 if (isDemotingSelf) {
391 window.location.href = "/";
392 } else {
393 await this.loadUsers();
394 }
395 } catch (err) {
396 this.error =
397 err instanceof Error ? err.message : "Failed to update user role";
398 select.value = oldRole;
399 }
400 }
401
402 @state() deleteState: {
403 id: number;
404 type: "user" | "revoke";
405 clicks: number;
406 timeout: number | null;
407 } | null = null;
408
409 private handleDeleteClick(userId: number, event: Event) {
410 event.stopPropagation();
411
412 // If this is a different item or timeout expired, reset
413 if (
414 !this.deleteState ||
415 this.deleteState.id !== userId ||
416 this.deleteState.type !== "user"
417 ) {
418 // Clear any existing timeout
419 if (this.deleteState?.timeout) {
420 clearTimeout(this.deleteState.timeout);
421 }
422
423 // Set first click
424 const timeout = window.setTimeout(() => {
425 this.deleteState = null;
426 }, 1000);
427
428 this.deleteState = { id: userId, type: "user", clicks: 1, timeout };
429 return;
430 }
431
432 // Increment clicks
433 const newClicks = this.deleteState.clicks + 1;
434
435 // Clear existing timeout
436 if (this.deleteState.timeout) {
437 clearTimeout(this.deleteState.timeout);
438 }
439
440 // Third click - actually delete
441 if (newClicks === 3) {
442 this.deleteState = null;
443 this.performDeleteUser(userId);
444 return;
445 }
446
447 // Second click - reset timeout
448 const timeout = window.setTimeout(() => {
449 this.deleteState = null;
450 }, 1000);
451
452 this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout };
453 }
454
455 private async performDeleteUser(userId: number) {
456 this.error = null;
457 try {
458 const response = await fetch(`/api/admin/users/${userId}`, {
459 method: "DELETE",
460 });
461
462 if (!response.ok) {
463 const data = await response.json();
464 throw new Error(data.error || "Failed to delete user");
465 }
466
467 // Remove user from local array instead of reloading
468 this.users = this.users.filter((u) => u.id !== userId);
469 this.dispatchEvent(new CustomEvent("user-deleted"));
470 } catch (err) {
471 this.error =
472 err instanceof Error
473 ? err.message
474 : "Failed to delete user. Please try again.";
475 }
476 }
477
478 private handleRevokeClick(
479 userId: number,
480 email: string,
481 subscriptionId: string,
482 event: Event,
483 ) {
484 event.stopPropagation();
485
486 // If this is a different item or timeout expired, reset
487 if (
488 !this.deleteState ||
489 this.deleteState.id !== userId ||
490 this.deleteState.type !== "revoke"
491 ) {
492 // Clear any existing timeout
493 if (this.deleteState?.timeout) {
494 clearTimeout(this.deleteState.timeout);
495 }
496
497 // Set first click
498 const timeout = window.setTimeout(() => {
499 this.deleteState = null;
500 }, 1000);
501
502 this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout };
503 return;
504 }
505
506 // Increment clicks
507 const newClicks = this.deleteState.clicks + 1;
508
509 // Clear existing timeout
510 if (this.deleteState.timeout) {
511 clearTimeout(this.deleteState.timeout);
512 }
513
514 // Third click - actually revoke
515 if (newClicks === 3) {
516 this.deleteState = null;
517 this.performRevokeSubscription(userId, email, subscriptionId);
518 return;
519 }
520
521 // Second click - reset timeout
522 const timeout = window.setTimeout(() => {
523 this.deleteState = null;
524 }, 1000);
525
526 this.deleteState = {
527 id: userId,
528 type: "revoke",
529 clicks: newClicks,
530 timeout,
531 };
532 }
533
534 private async performRevokeSubscription(
535 userId: number,
536 _email: string,
537 subscriptionId: string,
538 ) {
539 this.revokingSubscriptions.add(userId);
540 this.requestUpdate();
541 this.error = null;
542
543 try {
544 const response = await fetch(`/api/admin/users/${userId}/subscription`, {
545 method: "DELETE",
546 headers: { "Content-Type": "application/json" },
547 body: JSON.stringify({ subscriptionId }),
548 });
549
550 if (!response.ok) {
551 const data = await response.json();
552 throw new Error(data.error || "Failed to revoke subscription");
553 }
554
555 await this.loadUsers();
556 } catch (err) {
557 this.error =
558 err instanceof Error ? err.message : "Failed to revoke subscription";
559 this.revokingSubscriptions.delete(userId);
560 }
561 }
562
563 private async handleSyncSubscription(userId: number, event: Event) {
564 event.stopPropagation();
565
566 this.syncingSubscriptions.add(userId);
567 this.requestUpdate();
568 this.error = null;
569
570 try {
571 const response = await fetch(`/api/admin/users/${userId}/subscription`, {
572 method: "PUT",
573 headers: { "Content-Type": "application/json" },
574 });
575
576 if (!response.ok) {
577 const data = await response.json();
578 // Don't show error if there's just no subscription
579 if (response.status !== 404) {
580 this.error = data.error || "Failed to sync subscription";
581 }
582 return;
583 }
584
585 await this.loadUsers();
586 } finally {
587 this.syncingSubscriptions.delete(userId);
588 this.requestUpdate();
589 }
590 }
591
592 private getDeleteButtonText(userId: number, type: "user" | "revoke"): string {
593 if (
594 !this.deleteState ||
595 this.deleteState.id !== userId ||
596 this.deleteState.type !== type
597 ) {
598 return type === "user" ? "Delete User" : "Revoke Subscription";
599 }
600
601 if (this.deleteState.clicks === 1) {
602 return "Are you sure?";
603 }
604
605 if (this.deleteState.clicks === 2) {
606 return "Final warning!";
607 }
608
609 return type === "user" ? "Delete User" : "Revoke Subscription";
610 }
611
612 private handleCardClick(userId: number, event: Event) {
613 // Don't open modal for ghost user
614 if (userId === 0) {
615 return;
616 }
617
618 // Don't open modal if clicking on delete button, revoke button, sync button, or role select
619 if (
620 (event.target as HTMLElement).closest(".delete-btn") ||
621 (event.target as HTMLElement).closest(".revoke-btn") ||
622 (event.target as HTMLElement).closest(".sync-btn") ||
623 (event.target as HTMLElement).closest(".role-select")
624 ) {
625 return;
626 }
627 this.dispatchEvent(
628 new CustomEvent("open-user", {
629 detail: { id: userId },
630 }),
631 );
632 }
633
634 private formatTimestamp(timestamp: number | null): string {
635 if (!timestamp) return "Never";
636 const date = new Date(timestamp * 1000);
637 return date.toLocaleString();
638 }
639
640 private get filteredUsers() {
641 const query = this.searchQuery.toLowerCase();
642
643 // Filter users based on search query
644 let filtered = this.users.filter(
645 (u) =>
646 u.email.toLowerCase().includes(query) ||
647 u.name?.toLowerCase().includes(query),
648 );
649
650 // Hide ghost user unless specifically searched for
651 if (
652 !query.includes("deleted") &&
653 !query.includes("ghost") &&
654 !query.includes("system")
655 ) {
656 filtered = filtered.filter((u) => u.id !== 0);
657 }
658
659 return filtered;
660 }
661
662 override render() {
663 if (this.isLoading) {
664 return html`<div class="loading">Loading users...</div>`;
665 }
666
667 if (this.error) {
668 return html`
669 <div class="error-banner">${this.error}</div>
670 <button @click=${this.loadUsers}>Retry</button>
671 `;
672 }
673
674 const filtered = this.filteredUsers;
675
676 return html`
677 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""}
678
679 <input
680 type="text"
681 class="search-box"
682 placeholder="Search by name or email..."
683 .value=${this.searchQuery}
684 @input=${(e: Event) => {
685 this.searchQuery = (e.target as HTMLInputElement).value;
686 }}
687 />
688
689 ${
690 filtered.length === 0
691 ? html`<div class="empty-state">No users found</div>`
692 : html`
693 <div class="users-grid">
694 ${filtered.map(
695 (u) => html`
696 <div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
697 <div class="card-header">
698 <div class="user-info">
699 <img
700 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
701 alt="Avatar"
702 class="user-avatar"
703 />
704 <div class="user-details">
705 <div class="user-name">${u.name || "Anonymous"}</div>
706 <div class="user-email">${u.email}</div>
707 </div>
708 </div>
709 ${
710 u.id === 0
711 ? html`<span class="system-badge">System</span>`
712 : u.role === "admin"
713 ? html`<span class="admin-badge">Admin</span>`
714 : ""
715 }
716 </div>
717
718 <div class="meta-row">
719 <div class="meta-item">
720 <div class="meta-label">Transcriptions</div>
721 <div class="meta-value">${u.transcription_count}</div>
722 </div>
723 <div class="meta-item">
724 <div class="meta-label">Subscription</div>
725 <div class="meta-value">
726 ${
727 u.subscription_status
728 ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
729 : html`<span class="subscription-badge none">None</span>`
730 }
731 </div>
732 </div>
733 <div class="meta-item">
734 <div class="meta-label">Last Login</div>
735 <div class="meta-value timestamp">
736 ${this.formatTimestamp(u.last_login)}
737 </div>
738 </div>
739 <div class="meta-item">
740 <div class="meta-label">Joined</div>
741 <div class="meta-value timestamp">
742 ${this.formatTimestamp(u.created_at)}
743 </div>
744 </div>
745 </div>
746
747 <div class="actions">
748 ${
749 u.id === 0
750 ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
751 : html`
752 <select
753 class="role-select"
754 .value=${u.role}
755 @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)}
756 >
757 <option value="user">User</option>
758 <option value="admin">Admin</option>
759 </select>
760 <button
761 class="sync-btn"
762 ?disabled=${this.syncingSubscriptions.has(u.id)}
763 @click=${(e: Event) => this.handleSyncSubscription(u.id, e)}
764 title="Sync subscription status from Polar"
765 >
766 ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "馃攧 Sync"}
767 </button>
768 <button
769 class="revoke-btn"
770 ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
771 @click=${(e: Event) => {
772 if (u.subscription_id) {
773 this.handleRevokeClick(
774 u.id,
775 u.email,
776 u.subscription_id,
777 e,
778 );
779 }
780 }}
781 >
782 ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")}
783 </button>
784 <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, e)}>
785 ${this.getDeleteButtonText(u.id, "user")}
786 </button>
787 `
788 }
789 </div>
790 </div>
791 `,
792 )}
793 </div>
794 `
795 }
796 `;
797 }
798}