import { css, html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; interface User { id: number; email: string; name: string | null; avatar: string; role: "user" | "admin"; transcription_count: number; last_login: number | null; created_at: number; subscription_status: string | null; subscription_id: string | null; } @customElement("admin-users") export class AdminUsers extends LitElement { @state() users: User[] = []; @state() searchQuery = ""; @state() isLoading = true; @state() error: string | null = null; @state() currentUserEmail: string | null = null; @state() revokingSubscriptions = new Set(); @state() syncingSubscriptions = new Set(); static override styles = css` :host { display: block; } .error-banner { background: #fecaca; border: 2px solid rgba(220, 38, 38, 0.8); border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; color: #dc2626; font-weight: 500; } .search-box { width: 100%; max-width: 30rem; margin-bottom: 1.5rem; padding: 0.75rem 1rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 1rem; background: var(--background); color: var(--text); } .search-box:focus { outline: none; border-color: var(--primary); } .loading, .empty-state { text-align: center; padding: 3rem; color: var(--paynes-gray); } .error { background: color-mix(in srgb, red 10%, transparent); border: 1px solid red; color: red; padding: 1rem; border-radius: 4px; margin-bottom: 1rem; } .users-grid { display: grid; gap: 1rem; } .user-card { background: var(--background); border: 2px solid var(--secondary); border-radius: 8px; padding: 1.5rem; cursor: pointer; transition: border-color 0.2s; } .user-card:hover { border-color: var(--primary); } .user-card.system { cursor: default; opacity: 0.8; } .user-card.system:hover { border-color: var(--secondary); } .card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; } .user-info { display: flex; align-items: center; gap: 1rem; } .user-avatar { width: 3rem; height: 3rem; border-radius: 50%; } .user-details { flex: 1; } .user-name { font-size: 1.125rem; font-weight: 600; color: var(--text); margin-bottom: 0.25rem; } .user-email { font-size: 0.875rem; color: var(--paynes-gray); } .admin-badge { background: var(--accent); color: var(--white); padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .system-badge { background: var(--paynes-gray); color: var(--white); padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .meta-row { display: flex; gap: 2rem; flex-wrap: wrap; margin-bottom: 1rem; } .meta-item { display: flex; flex-direction: column; gap: 0.25rem; } .meta-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--paynes-gray); letter-spacing: 0.05em; } .meta-value { font-size: 0.875rem; color: var(--text); } .timestamp { color: var(--paynes-gray); font-size: 0.875rem; } .actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; } .role-select { padding: 0.5rem 0.75rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; background: var(--background); color: var(--text); cursor: pointer; font-weight: 600; } .role-select:focus { outline: none; border-color: var(--primary); } .delete-btn { background: transparent; border: 2px solid #dc2626; color: #dc2626; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600; transition: all 0.2s; } .delete-btn:hover:not(:disabled) { background: #dc2626; color: var(--white); } .delete-btn:disabled { opacity: 0.5; cursor: not-allowed; } .revoke-btn { background: transparent; border: 2px solid var(--accent); color: var(--accent); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600; transition: all 0.2s; } .revoke-btn:hover:not(:disabled) { background: var(--accent); color: var(--white); } .revoke-btn:disabled { opacity: 0.5; cursor: not-allowed; } .sync-btn { background: transparent; border: 2px solid var(--primary); color: var(--primary); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600; transition: all 0.2s; } .sync-btn:hover:not(:disabled) { background: var(--primary); color: var(--white); } .sync-btn:disabled { opacity: 0.5; cursor: not-allowed; } .subscription-badge { background: var(--primary); color: var(--white); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .subscription-badge.active { background: var(--primary); color: var(--white); } .subscription-badge.none { background: var(--secondary); color: var(--paynes-gray); } `; override async connectedCallback() { super.connectedCallback(); await this.getCurrentUser(); await this.loadUsers(); } private async getCurrentUser() { try { const response = await fetch("/api/auth/me"); if (response.ok) { const user = await response.json(); this.currentUserEmail = user.email; } } catch { // Silent fail } } private async loadUsers() { this.isLoading = true; this.error = null; try { const response = await fetch("/api/admin/users"); if (!response.ok) { const data = await response.json(); throw new Error(data.error || "Failed to load users"); } this.users = await response.json(); } catch (err) { this.error = err instanceof Error ? err.message : "Failed to load users. Please try again."; } finally { this.isLoading = false; } } private async handleRoleChange( userId: number, email: string, newRole: "user" | "admin", oldRole: "user" | "admin", event: Event, ) { const select = event.target as HTMLSelectElement; const isDemotingSelf = email === this.currentUserEmail && oldRole === "admin" && newRole === "user"; if (isDemotingSelf) { if ( !confirm( "⚠️ WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?", ) ) { select.value = oldRole; return; } if ( !confirm( "⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?", ) ) { select.value = oldRole; return; } } else { if (!confirm(`Change user role to ${newRole}?`)) { select.value = oldRole; return; } } try { const response = await fetch(`/api/admin/users/${userId}/role`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: newRole }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || "Failed to update role"); } if (isDemotingSelf) { window.location.href = "/"; } else { await this.loadUsers(); } } catch (err) { this.error = err instanceof Error ? err.message : "Failed to update user role"; select.value = oldRole; } } @state() deleteState: { id: number; type: "user" | "revoke"; clicks: number; timeout: number | null; } | null = null; private handleDeleteClick(userId: number, event: Event) { event.stopPropagation(); // If this is a different item or timeout expired, reset if ( !this.deleteState || this.deleteState.id !== userId || this.deleteState.type !== "user" ) { // Clear any existing timeout if (this.deleteState?.timeout) { clearTimeout(this.deleteState.timeout); } // Set first click const timeout = window.setTimeout(() => { this.deleteState = null; }, 1000); this.deleteState = { id: userId, type: "user", clicks: 1, timeout }; return; } // Increment clicks const newClicks = this.deleteState.clicks + 1; // Clear existing timeout if (this.deleteState.timeout) { clearTimeout(this.deleteState.timeout); } // Third click - actually delete if (newClicks === 3) { this.deleteState = null; this.performDeleteUser(userId); return; } // Second click - reset timeout const timeout = window.setTimeout(() => { this.deleteState = null; }, 1000); this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout }; } private async performDeleteUser(userId: number) { this.error = null; try { const response = await fetch(`/api/admin/users/${userId}`, { method: "DELETE", }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || "Failed to delete user"); } // Remove user from local array instead of reloading this.users = this.users.filter(u => u.id !== userId); this.dispatchEvent(new CustomEvent("user-deleted")); } catch (err) { this.error = err instanceof Error ? err.message : "Failed to delete user. Please try again."; } } private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) { event.stopPropagation(); // If this is a different item or timeout expired, reset if ( !this.deleteState || this.deleteState.id !== userId || this.deleteState.type !== "revoke" ) { // Clear any existing timeout if (this.deleteState?.timeout) { clearTimeout(this.deleteState.timeout); } // Set first click const timeout = window.setTimeout(() => { this.deleteState = null; }, 1000); this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout }; return; } // Increment clicks const newClicks = this.deleteState.clicks + 1; // Clear existing timeout if (this.deleteState.timeout) { clearTimeout(this.deleteState.timeout); } // Third click - actually revoke if (newClicks === 3) { this.deleteState = null; this.performRevokeSubscription(userId, email, subscriptionId); return; } // Second click - reset timeout const timeout = window.setTimeout(() => { this.deleteState = null; }, 1000); this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout }; } private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) { this.revokingSubscriptions.add(userId); this.requestUpdate(); this.error = null; try { const response = await fetch(`/api/admin/users/${userId}/subscription`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subscriptionId }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || "Failed to revoke subscription"); } await this.loadUsers(); } catch (err) { this.error = err instanceof Error ? err.message : "Failed to revoke subscription"; this.revokingSubscriptions.delete(userId); } } private async handleSyncSubscription(userId: number, event: Event) { event.stopPropagation(); this.syncingSubscriptions.add(userId); this.requestUpdate(); this.error = null; try { const response = await fetch(`/api/admin/users/${userId}/subscription`, { method: "PUT", headers: { "Content-Type": "application/json" }, }); if (!response.ok) { const data = await response.json(); // Don't show error if there's just no subscription if (response.status !== 404) { this.error = data.error || "Failed to sync subscription"; } return; } await this.loadUsers(); } finally { this.syncingSubscriptions.delete(userId); this.requestUpdate(); } } private getDeleteButtonText(userId: number, type: "user" | "revoke"): string { if ( !this.deleteState || this.deleteState.id !== userId || this.deleteState.type !== type ) { return type === "user" ? "Delete User" : "Revoke Subscription"; } if (this.deleteState.clicks === 1) { return "Are you sure?"; } if (this.deleteState.clicks === 2) { return "Final warning!"; } return type === "user" ? "Delete User" : "Revoke Subscription"; } private handleCardClick(userId: number, event: Event) { // Don't open modal for ghost user if (userId === 0) { return; } // Don't open modal if clicking on delete button, revoke button, sync button, or role select if ( (event.target as HTMLElement).closest(".delete-btn") || (event.target as HTMLElement).closest(".revoke-btn") || (event.target as HTMLElement).closest(".sync-btn") || (event.target as HTMLElement).closest(".role-select") ) { return; } this.dispatchEvent( new CustomEvent("open-user", { detail: { id: userId }, }), ); } private formatTimestamp(timestamp: number | null): string { if (!timestamp) return "Never"; const date = new Date(timestamp * 1000); return date.toLocaleString(); } private get filteredUsers() { const query = this.searchQuery.toLowerCase(); // Filter users based on search query let filtered = this.users.filter( (u) => u.email.toLowerCase().includes(query) || u.name?.toLowerCase().includes(query), ); // Hide ghost user unless specifically searched for if (!query.includes("deleted") && !query.includes("ghost") && !query.includes("system")) { filtered = filtered.filter(u => u.id !== 0); } return filtered; } override render() { if (this.isLoading) { return html`
Loading users...
`; } if (this.error) { return html`
${this.error}
`; } const filtered = this.filteredUsers; return html` ${this.error ? html`
${this.error}
` : ""} { this.searchQuery = (e.target as HTMLInputElement).value; }} /> ${ filtered.length === 0 ? html`
No users found
` : html`
${filtered.map( (u) => html`
this.handleCardClick(u.id, e)}>
${u.id === 0 ? html`System` : u.role === "admin" ? html`Admin` : "" }
Transcriptions
${u.transcription_count}
Subscription
${u.subscription_status ? html`${u.subscription_status}` : html`None` }
Last Login
${this.formatTimestamp(u.last_login)}
Joined
${this.formatTimestamp(u.created_at)}
${u.id === 0 ? html`
System account cannot be modified
` : html` ` }
`, )}
` } `; } }