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