馃 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}
14
15@customElement("admin-users")
16export class AdminUsers extends LitElement {
17 @state() users: User[] = [];
18 @state() searchQuery = "";
19 @state() isLoading = true;
20 @state() error: string | null = null;
21 @state() currentUserEmail: string | null = null;
22
23 static override styles = css`
24 :host {
25 display: block;
26 }
27
28 .search-box {
29 width: 100%;
30 max-width: 30rem;
31 margin-bottom: 1.5rem;
32 padding: 0.75rem 1rem;
33 border: 2px solid var(--secondary);
34 border-radius: 4px;
35 font-size: 1rem;
36 background: var(--background);
37 color: var(--text);
38 }
39
40 .search-box:focus {
41 outline: none;
42 border-color: var(--primary);
43 }
44
45 .loading,
46 .empty-state {
47 text-align: center;
48 padding: 3rem;
49 color: var(--paynes-gray);
50 }
51
52 .error {
53 background: color-mix(in srgb, red 10%, transparent);
54 border: 1px solid red;
55 color: red;
56 padding: 1rem;
57 border-radius: 4px;
58 margin-bottom: 1rem;
59 }
60
61 .users-grid {
62 display: grid;
63 gap: 1rem;
64 }
65
66 .user-card {
67 background: var(--background);
68 border: 2px solid var(--secondary);
69 border-radius: 8px;
70 padding: 1.5rem;
71 cursor: pointer;
72 transition: border-color 0.2s;
73 }
74
75 .user-card:hover {
76 border-color: var(--primary);
77 }
78
79 .card-header {
80 display: flex;
81 justify-content: space-between;
82 align-items: flex-start;
83 margin-bottom: 1rem;
84 }
85
86 .user-info {
87 display: flex;
88 align-items: center;
89 gap: 1rem;
90 }
91
92 .user-avatar {
93 width: 3rem;
94 height: 3rem;
95 border-radius: 50%;
96 }
97
98 .user-details {
99 flex: 1;
100 }
101
102 .user-name {
103 font-size: 1.125rem;
104 font-weight: 600;
105 color: var(--text);
106 margin-bottom: 0.25rem;
107 }
108
109 .user-email {
110 font-size: 0.875rem;
111 color: var(--paynes-gray);
112 }
113
114 .admin-badge {
115 background: var(--accent);
116 color: var(--white);
117 padding: 0.5rem 1rem;
118 border-radius: 4px;
119 font-size: 0.75rem;
120 font-weight: 600;
121 text-transform: uppercase;
122 }
123
124 .meta-row {
125 display: flex;
126 gap: 2rem;
127 flex-wrap: wrap;
128 margin-bottom: 1rem;
129 }
130
131 .meta-item {
132 display: flex;
133 flex-direction: column;
134 gap: 0.25rem;
135 }
136
137 .meta-label {
138 font-size: 0.75rem;
139 font-weight: 600;
140 text-transform: uppercase;
141 color: var(--paynes-gray);
142 letter-spacing: 0.05em;
143 }
144
145 .meta-value {
146 font-size: 0.875rem;
147 color: var(--text);
148 }
149
150 .timestamp {
151 color: var(--paynes-gray);
152 font-size: 0.875rem;
153 }
154
155 .actions {
156 display: flex;
157 gap: 0.75rem;
158 align-items: center;
159 flex-wrap: wrap;
160 }
161
162 .role-select {
163 padding: 0.5rem 0.75rem;
164 border: 2px solid var(--secondary);
165 border-radius: 4px;
166 font-size: 0.875rem;
167 background: var(--background);
168 color: var(--text);
169 cursor: pointer;
170 font-weight: 600;
171 }
172
173 .role-select:focus {
174 outline: none;
175 border-color: var(--primary);
176 }
177
178 .delete-btn {
179 background: transparent;
180 border: 2px solid #dc2626;
181 color: #dc2626;
182 padding: 0.5rem 1rem;
183 border-radius: 4px;
184 cursor: pointer;
185 font-size: 0.875rem;
186 font-weight: 600;
187 transition: all 0.2s;
188 }
189
190 .delete-btn:hover:not(:disabled) {
191 background: #dc2626;
192 color: var(--white);
193 }
194
195 .delete-btn:disabled {
196 opacity: 0.5;
197 cursor: not-allowed;
198 }
199 `;
200
201 override async connectedCallback() {
202 super.connectedCallback();
203 await this.getCurrentUser();
204 await this.loadUsers();
205 }
206
207 private async getCurrentUser() {
208 try {
209 const response = await fetch("/api/auth/me");
210 if (response.ok) {
211 const user = await response.json();
212 this.currentUserEmail = user.email;
213 }
214 } catch (error) {
215 console.error("Failed to get current user:", error);
216 }
217 }
218
219 private async loadUsers() {
220 this.isLoading = true;
221 this.error = null;
222
223 try {
224 const response = await fetch("/api/admin/users");
225 if (!response.ok) {
226 throw new Error("Failed to load users");
227 }
228
229 this.users = await response.json();
230 } catch (error) {
231 console.error("Failed to load users:", error);
232 this.error = "Failed to load users. Please try again.";
233 } finally {
234 this.isLoading = false;
235 }
236 }
237
238 private async handleRoleChange(userId: number, email: string, newRole: "user" | "admin", oldRole: "user" | "admin", event: Event) {
239 const select = event.target as HTMLSelectElement;
240
241 const isDemotingSelf =
242 email === this.currentUserEmail &&
243 oldRole === "admin" &&
244 newRole === "user";
245
246 if (isDemotingSelf) {
247 if (
248 !confirm(
249 "鈿狅笍 WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?",
250 )
251 ) {
252 select.value = oldRole;
253 return;
254 }
255
256 if (
257 !confirm(
258 "鈿狅笍 FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?",
259 )
260 ) {
261 select.value = oldRole;
262 return;
263 }
264 } else {
265 if (!confirm(`Change user role to ${newRole}?`)) {
266 select.value = oldRole;
267 return;
268 }
269 }
270
271 try {
272 const response = await fetch(`/api/admin/users/${userId}/role`, {
273 method: "PUT",
274 headers: { "Content-Type": "application/json" },
275 body: JSON.stringify({ role: newRole }),
276 });
277
278 if (!response.ok) {
279 throw new Error("Failed to update role");
280 }
281
282 if (isDemotingSelf) {
283 window.location.href = "/";
284 } else {
285 await this.loadUsers();
286 }
287 } catch (error) {
288 console.error("Failed to update role:", error);
289 alert("Failed to update user role");
290 select.value = oldRole;
291 }
292 }
293
294 private async handleDelete(userId: number, email: string) {
295 if (
296 !confirm(
297 `Are you sure you want to delete user ${email}? This will delete all their transcriptions and cannot be undone.`,
298 )
299 ) {
300 return;
301 }
302
303 try {
304 const response = await fetch(`/api/admin/users/${userId}`, {
305 method: "DELETE",
306 });
307
308 if (!response.ok) {
309 throw new Error("Failed to delete user");
310 }
311
312 await this.loadUsers();
313 this.dispatchEvent(new CustomEvent("user-deleted"));
314 } catch (error) {
315 console.error("Failed to delete user:", error);
316 alert("Failed to delete user. Please try again.");
317 }
318 }
319
320 private handleCardClick(userId: number, event: Event) {
321 // Don't open modal if clicking on delete button or role select
322 if (
323 (event.target as HTMLElement).closest(".delete-btn") ||
324 (event.target as HTMLElement).closest(".role-select")
325 ) {
326 return;
327 }
328 this.dispatchEvent(
329 new CustomEvent("open-user", {
330 detail: { id: userId },
331 }),
332 );
333 }
334
335 private formatTimestamp(timestamp: number | null): string {
336 if (!timestamp) return "Never";
337 const date = new Date(timestamp * 1000);
338 return date.toLocaleString();
339 }
340
341 private get filteredUsers() {
342 if (!this.searchQuery) return this.users;
343
344 const query = this.searchQuery.toLowerCase();
345 return this.users.filter(
346 (u) =>
347 u.email.toLowerCase().includes(query) ||
348 (u.name && u.name.toLowerCase().includes(query)),
349 );
350 }
351
352 override render() {
353 if (this.isLoading) {
354 return html`<div class="loading">Loading users...</div>`;
355 }
356
357 if (this.error) {
358 return html`
359 <div class="error">${this.error}</div>
360 <button @click=${this.loadUsers}>Retry</button>
361 `;
362 }
363
364 const filtered = this.filteredUsers;
365
366 return html`
367 <input
368 type="text"
369 class="search-box"
370 placeholder="Search by name or email..."
371 .value=${this.searchQuery}
372 @input=${(e: Event) => {
373 this.searchQuery = (e.target as HTMLInputElement).value;
374 }}
375 />
376
377 ${
378 filtered.length === 0
379 ? html`<div class="empty-state">No users found</div>`
380 : html`
381 <div class="users-grid">
382 ${filtered.map(
383 (u) => html`
384 <div class="user-card" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
385 <div class="card-header">
386 <div class="user-info">
387 <img
388 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
389 alt="Avatar"
390 class="user-avatar"
391 />
392 <div class="user-details">
393 <div class="user-name">${u.name || "Anonymous"}</div>
394 <div class="user-email">${u.email}</div>
395 </div>
396 </div>
397 ${u.role === "admin" ? html`<span class="admin-badge">Admin</span>` : ""}
398 </div>
399
400 <div class="meta-row">
401 <div class="meta-item">
402 <div class="meta-label">Transcriptions</div>
403 <div class="meta-value">${u.transcription_count}</div>
404 </div>
405 <div class="meta-item">
406 <div class="meta-label">Last Login</div>
407 <div class="meta-value timestamp">
408 ${this.formatTimestamp(u.last_login)}
409 </div>
410 </div>
411 <div class="meta-item">
412 <div class="meta-label">Joined</div>
413 <div class="meta-value timestamp">
414 ${this.formatTimestamp(u.created_at)}
415 </div>
416 </div>
417 </div>
418
419 <div class="actions">
420 <select
421 class="role-select"
422 .value=${u.role}
423 @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)}
424 >
425 <option value="user">User</option>
426 <option value="admin">Admin</option>
427 </select>
428 <button class="delete-btn" @click=${() => this.handleDelete(u.id, u.email)}>
429 Delete User
430 </button>
431 </div>
432 </div>
433 `,
434 )}
435 </div>
436 `
437 }
438 `;
439 }
440}