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