馃 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}