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