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